過密です

タイトルしょうもなさすぎたので変えました

CTFで出題されるmusl libc問あれこれ

はじめに

本エントリはCTF Advent Calender 2021の21日目の記事です。
前はEdwow Mathさんの記事、次はXornetさんの記事になっております。

投稿遅れてしまって本当にすみません🙇‍♂️🙇‍♂️🙇‍♂️

以下、目次です。

背景

CTFのpwnジャンルにおいていわゆるheap問というとThe GNU C library(以下glibc)のmallocのメモリ管理機構を利用した問題が頻出ですが、今年のDEFCON予選で出題されたmoooslという問題でmusl libcのpwnを目にして以来、結構大きめの大会でも出題されるのを見かけるようになった気がするので、実際に大会で出題された過去問のパターンをいくつか紹介したいという内容です。 (CTF歴が浅いので、もしかしたら今年以前にも出題があったかもしれません)

前提

musl libcについて

公式サイト

musl.libc.org

musl is an implementation of the C standard library built on top of the Linux system call API, including interfaces defined in the base language standard, POSIX, and widely agreed-upon extensions. musl is lightweight, fast, simple, free, and strives to be correct in the sense of standards-conformance and safety.

とのこと。あまり詳しくないので深くは言及しないですが、組み込み分野で使用されることの多いalpine linuxで利用されている事例もあるそう。

実装

ここからpwn問題で使用しそうなmusl libcのmalloc/freeとfile構造体について簡単に説明しようと思います。
現時点(投稿時点)での最新versionが1.2.2であり、自分が把握している過去問の3/4が1.2.2での出題でした。 1問だけ1.1.24の問題もありましたが、ここでは1.2.2だけ触れます。

malloc/free

ソースコードはsrc/malloc/mallocng内のファイルを参照してください。

musl libcでは確保領域(以下chunk)の管理方法が大きく3つの部分に分かれています。
それぞれlibc全体での管理領域のmalloc_context,
同じsizeの1まとまりのchunkを管理するmeta,
実際にユーザに返すアドレスを含んだ領域であるgroupです。

src/malloc/mallocng/meta.h より

struct group {
    struct meta *meta;
    unsigned char active_idx:5;
    char pad[UNIT - sizeof(struct meta *) - 1];
    unsigned char storage[];
};

struct meta {
    struct meta *prev, *next;
    struct group *mem;
    volatile int avail_mask, freed_mask;
    uintptr_t last_idx:5;
    uintptr_t freeable:1;
    uintptr_t sizeclass:6;
    uintptr_t maplen:8*sizeof(uintptr_t)-12;
};

...

struct malloc_context {
    uint64_t secret;
#ifndef PAGESIZE
    size_t pagesize;
#endif
    int init_done;
    unsigned mmap_counter;
    struct meta *free_meta_head;
    struct meta *avail_meta;
    size_t avail_meta_count, avail_meta_area_count, meta_alloc_shift;
    struct meta_area *meta_area_head, *meta_area_tail;
    unsigned char *avail_meta_areas;
    struct meta *active[48];
    size_t usage_by_class[48];
    uint8_t unmap_seq[32], bounces[32];
    uint8_t seq;
    uintptr_t brk;
};

例として以下のような操作をした際の実際のメモリを見てみます。
下記ではサイズが切り上げられて4つのchunkは全て0x30サイズのchunkとして管理されます。

char* a,b,c,d;
a = malloc(0x20); // "AAAA\n"
b = malloc(0x2a); // "BBBB\n"
c = malloc(0x2c); // "CCCC\n"
d = malloc(0x2c); // "DDDD\n"

free(c)

malloc_contextのメモリダンプ(先頭から抜粋なので実際はもっと長いです)
0x30のmeta領域を指すのはmalloc_context.active[2]なので、0x7ffff7ffbb40に格納された値がmetaのアドレスです。

0x7ffff7ffbae0 <__malloc_context>:      0x1f93d0d4ab298ae4      0x0000000000000001
0x7ffff7ffbaf0 <__malloc_context+16>:   0x0000000000000000      0x00007ffff80001a8
0x7ffff7ffbb00 <__malloc_context+32>:   0x000000000000005b      0x0000000000000000
0x7ffff7ffbb10 <__malloc_context+48>:   0x0000000000000000      0x00007ffff8000000
0x7ffff7ffbb20 <__malloc_context+64>:   0x00007ffff8000000      0x00007ffff8001000
0x7ffff7ffbb30 <__malloc_context+80>:   0x00007ffff8000158      0x0000000000000000
0x7ffff7ffbb40 <__malloc_context+96>:   0x00007ffff8000180      0x0000000000000000
0x7ffff7ffbb50 <__malloc_context+112>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbb60 <__malloc_context+128>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbb70 <__malloc_context+144>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbb80 <__malloc_context+160>:  0x0000000000000000      0x00007ffff8000090
0x7ffff7ffbb90 <__malloc_context+176>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbba0 <__malloc_context+192>:  0x0000000000000000      0x00007ffff8000068
0x7ffff7ffbbb0 <__malloc_context+208>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbbc0 <__malloc_context+224>:  0x0000000000000000      0x00007ffff8000040
0x7ffff7ffbbd0 <__malloc_context+240>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbbe0 <__malloc_context+256>:  0x0000000000000000      0x00007ffff8000018
0x7ffff7ffbbf0 <__malloc_context+272>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbc00 <__malloc_context+288>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbc10 <__malloc_context+304>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbc20 <__malloc_context+320>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbc30 <__malloc_context+336>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbc40 <__malloc_context+352>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbc50 <__malloc_context+368>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbc60 <__malloc_context+384>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbc70 <__malloc_context+400>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbc80 <__malloc_context+416>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbc90 <__malloc_context+432>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbca0 <__malloc_context+448>:  0x0000000000000000      0x0000000000000000
0x7ffff7ffbcb0 <__malloc_context+464>:  0x000000000000001e      0x0000000000000000
0x7ffff7ffbcc0 <__malloc_context+480>:  0x000000000000000a      0x0000000000000000
0x7ffff7ffbcd0 <__malloc_context+496>:  0x0000000000000000      0x0000000000000000

続いてmeta領域です。

0x7ffff8000180: 0x00007ffff8000180      0x00007ffff8000180
0x7ffff8000190: 0x00007ffff7d65cd0      0x00000004000003f0
0x7ffff80001a0: 0x00000000000000a9      0x0000000000000000

先頭のアドレスはサイズ0x30のmeta領域の双方向リストで、上の例だとまだ一つしかmeta領域が獲得されていないので自分自身とつながっています。
offset=0x10のアドレスがgroup領域になります。実際にユーザに返される領域はこのgroup+0xXXのアドレスになります。
offset=0x18の値はそれぞれavail_maskが0x3f0, freed_maskが0x4となっています。これは現在使用できる領域、解放済みの領域のoffsetを表すもので、上記例だと4つ獲得したのでavail_maskの下位4bitが0になっていて、3個目のchunkをfreeしたのでfree_maskの下位3bit目が1になっています。

最後にgroup領域です。

0x7ffff7d65cd0: 0x00007ffff8000180      0x0000a00000000009
0x7ffff7d65ce0: 0x0000000a41414141      0x0000000000000000
0x7ffff7d65cf0: 0x0000000000000000      0x0000000000000000
0x7ffff7d65d00: 0x0000000000000000      0x000341000000000c
0x7ffff7d65d10: 0x0000000a42424242      0x0000000000000000
0x7ffff7d65d20: 0x0000000000000000      0x0000000000000000
0x7ffff7d65d30: 0x0000000000000000      0x0000ff0000000000
0x7ffff7d65d40: 0x0000000a43434343      0x0000000000000000
0x7ffff7d65d50: 0x0000000000000000      0x0000000000000000
0x7ffff7d65d60: 0x0000000000000000      0x0009030000000000
0x7ffff7d65d70: 0x0000000a44444444      0x0000000000000000
0x7ffff7d65d80: 0x0000000000000000      0x0000000000000000
0x7ffff7d65d90: 0x0000000000000000      0x0000000000000000
0x7ffff7d65da0: 0x0000000000000000      0x0000000000000000

先頭はmeta領域へのポインタです。chunk周辺にglibcとは違ったメタデータが見えます。

ソースを見ればわかりますが2個目のchunkの例を少し説明すると、 (uint16_t)ptr[-2] * 0x10がそのchunkのgruopからのoffsetになります。メモリ上だと0x000341の0x0003が獲得可能な先頭(0x7ffff7d65ce0)から0x30離れていることを表しています。

また0x000341の0x41については0x41 >> 5 = 2で、これはサイズ0x30のchunkの最大で確保できる領域(0x2c)-要求サイズ(0x2a)を表しています。(ソースコード上はreservedという変数) 残りの0x41の下位5bitは(0x41 & 0x1f = 1)group内のoffsetを表しています。 reseved が5以上の場合はreservedの値をchunkの末尾に書いて先述のptr[-3]にはreserved = 5として記録しています。
1個目のchunkがそれに該当し0x2c-0x20=0xcがchunkの末尾に記録されています。これらのメタデータソースコード上だとmeta.hのset_size()の箇所がこの操作を行なっているところになります。

ここまで管理手法についてざっくり説明しましたが、 次にmalloc / freeの挙動についても重要なところをかいつまんで説明しようと思います。

malloc

かなり適当ですがmallocはこんな雰囲気のフローです

  1. 要求サイズがthresholdを超えているか
    true -> mmapしてそれを利用(省略)
    false-> 2へ
  2. 使用するmeta *gを特定
    ない-> alloc_slot()で領域確保して4へ
  3. g->avail_maskに空きがあるか確認
    ある -> indexを指定してmaskを更新
    ない-> alloc_slot()で領域確保
  4. 使用するmeta領域とそのindexが決まったのでenframe()でサイズなどのメタデータを付与

実際にはもっと内部で色々なことをしていますが、assert()でバイナリが死ぬ箇所はそこまで多くない印象です。 mallocで一番覚えておくべき挙動としては 使用するmeta領域にfreedなchunkがあってもavail_maskのindexが小さいものからchunkを優先して取得するということです。
freedなchunkはavail_maskが0になった時に一斉にfreed_mask => avail_maskとなり、獲得される順番はglibcの直近で使用していたchunkから取得する思想とは異なるので注意が必要です。

free

freeは基本的にp[-3]=0xffなどのchunkのメタデータを更新するだけですが、groupの全てのchunkがfreed または avail(使用可能)になった時に呼ばれるnontrivial_free()がなかなか重要なのでそこだけ軽く説明します。

static struct mapinfo nontrivial_free(struct meta *g, int i)
{   
    uint32_t self = 1u<<i;
    int sc = g->sizeclass;
    uint32_t mask = g->freed_mask | g->avail_mask;

    if (mask+self == (2u<<g->last_idx)-1 && okay_to_free(g)) {
        // any multi-slot group is necessarily on an active list
        // here, but single-slot groups might or might not be.
        if (g->next) {
            assert(sc < 48);
            int activate_new = (ctx.active[sc]==g);
            dequeue(&ctx.active[sc], g);
            if (activate_new && ctx.active[sc])
                activate_group(ctx.active[sc]);
        }
        return free_group(g);
    } else if (!mask) {
        assert(sc < 48);
        // might still be active if there were no allocations
        // after last available slot was taken.
        if (ctx.active[sc] != g) {
            queue(&ctx.active[sc], g);
        }
    }
    a_or(&g->freed_mask, self);
    return (struct mapinfo){ 0 };
}

ソースコード内のifがtrueとなった場合に呼ばれるdequeue()でunlink attackが可能です。ここでいうunlink attackは8bytesのAAWなので問題を解く際はかなり重宝します。
具体例としては、偽のmeta領域を用意してそのmetaにつながるgroupのchunkをfreeすることで偽のmeta領域のnext,prevの値を元にunlink attackが可能となります。
ただこの流れの中でassert()によるvalidationが結構な数あったので、適切なフローを通るように各種メタデータを適切に設定できるよう実際にコードを書いて慣れておくのが良いと思います。
自分はかなり沼にハマってしまいました。

またelse if 内のqueueもmalloc_contextのactive[]に偽のmeta領域をつなぐなどに使えます。

あとは地味にfree時にユーザが書き込んだデータが破損されないのも問題を解く際にはありがたいですね。
低コストで先頭8bytesを0埋めとかでもかなり変わってくるのではないかと思いますが。もしかしたら今後アップデートで変わるかもしれません。

FILE構造体(FSOP)

glibcと違って_malloc_hookや_free_hookがmusl libcにはないため、AAW可能な場合などにRIPを奪うための有効な手段です。
ソースコードは以下
src/internal/stdio-impl.h

struct _IO_FILE { 
    unsigned flags;                                                                                      
    unsigned char *rpos, *rend;                                                                          
    int (*close)(FILE *);                                                                                
    unsigned char *wend, *wpos;                                                                          
    unsigned char *mustbezero_1;                                                                         
    unsigned char *wbase;
    size_t (*read)(FILE *, unsigned char *, size_t);                                                     
    size_t (*write)(FILE *, const unsigned char *, size_t);                                              
    off_t (*seek)(FILE *, off_t, int);                                                                   
    unsigned char *buf;
    size_t buf_size;                                                                                     
    FILE *prev, *next;                                                                                   
    int fd;
    int pipe_pid;
    long lockcount;
    int mode;
    volatile int lock;
    int lbf;                                                                                             
    void *cookie;                                                                                        
    off_t off;
    char *getln_buf;                                                                                     
    void *mustbezero_2;                                                                                  
    unsigned char *shend;                                                                                
    off_t shlim, shcnt;                                                                                  
    FILE *prev_locked, *next_locked;                                                                     
    struct __locale_struct *locale;                                                                      
};  

stdinの例
src/stdio/stdin.c

static unsigned char buf[BUFSIZ+UNGET];                                                                  
hidden FILE __stdin_FILE = {                                                                             
    .buf = buf+UNGET,                                                                                    
    .buf_size = sizeof buf-UNGET,                                                                        
    .fd = 0,
    .flags = F_PERM | F_NOWR,
    .read = __stdio_read,                                                                                
    .seek = __stdio_seek,
    .close = __stdio_close,                                                                              
    .lock = -1,                                                                                          
};
FILE *const stdin = &__stdin_FILE;                                                                       
FILE *volatile __stdin_used = &__stdin_FILE; 

実際のメモリダンプはこんな感じ

0x7ffff7ffb180: 0x0000000000000009      0x0000000000000000
0x7ffff7ffb190: 0x0000000000000000      0x00007ffff7fa39a0
0x7ffff7ffb1a0: 0x0000000000000000      0x0000000000000000
0x7ffff7ffb1b0: 0x0000000000000000      0x0000000000000000
0x7ffff7ffb1c0: 0x00007ffff7fa3a90      0x0000000000000000
0x7ffff7ffb1d0: 0x00007ffff7fa3b80      0x00007ffff7ffc2e8
0x7ffff7ffb1e0: 0x0000000000000400      0x0000000000000000
0x7ffff7ffb1f0: 0x0000000000000000      0x0000000000000000
0x7ffff7ffb200: 0x0000000000000000      0xffffffff00000000
0x7ffff7ffb210: 0x0000000000000000      0x0000000000000000
0x7ffff7ffb220: 0x0000000000000000      0x0000000000000000
0x7ffff7ffb230: 0x0000000000000000      0x0000000000000000
0x7ffff7ffb240: 0x0000000000000000      0x0000000000000000
0x7ffff7ffb250: 0x0000000000000000      0x0000000000000000
0x7ffff7ffb260: 0x0000000000000000      0x0000000000000000
0x7ffff7ffb270: 0x0000000000000000      0x0000000000000000

関数ポインタにあるread,write,seek,closeはいずれも自身のfile構造体のポインタを第一引数に取ります。
そのためfile構造体の先頭8byte(flagsメンバに位置するメモリ)を"/bin/sh\x00"に書き換えた上で関数を呼び出す方法などが使えます。

過去問

とりあえず自分が補足しているものを列挙しました。
他にも出題が確認されていたら教えてください🙏(解き直すかは別として)

ちなみに自分は大会中に解けてないので他人のwriteupを大いに参考にしております。
ソルバは参考にしたwriteupで十分かなと思ったので省略します。要望があればgithubとかにあげるかもです。

mooosl (DEFCON 2021 Quals)

version1.2.2からの出題でUAFの脆弱性があるバイナリの問題でした。

偽のmeta領域、group領域を作成してそれらをfreeし、管理領域malloc_contextに偽のデータを登録することで、偽の獲得領域からmallocできる用意する解法が使われていました。 AAWが可能になったあとはstdoutを書き換えてシェルを起動するよう流れです。

偽の獲得領域から取得する時のvalidationのbypassのためにunlink attackが必要で 偽の領域をmalloc_contextに登録するのが思いのほかだるいです。この問題を解いておけばだいぶmusl libcのmalloc/heapの面倒くささ、大まかな挙動が掴めると思いますが、後述の問題の方が練習向きだと思います。
自分は丸2日くらい溶かしました。配布されたlibcのシンボル情報が一致しない(これなんで)のでめちゃくちゃデバッグがしんどかった。。

参考writeup

https://h-noson.hatenablog.jp/entry/2021/05/03/161933

すでに日本語の記事があり大変わかりやすい🙏

musl (RCTF 2021)

続いてもversion 1.2.2からの出題。

seccompが有効なためシェル起動ではなくflagをorw(open,read,write)する問題。
バイナリも複雑なものではないので、練習としてこの問題から解いてみるのもありかと。

またRIPを奪った後に便利なROP gadgetがmusl libcにはあるみたい。

0x0004a5ae: mov rsp, qword [rdi+0x30] ; jmp qword [rdi+0x38] ;  (1 found)

いわゆるstack pivot的なもので、後述の問題でも使うテクニックなので覚えておいて損はないと思います。
FSOPで使う例としては、関数ポインタをこのgadgetにしてファイル構造体のaddress+0x30, +0x38にそれぞれpivot後のstackのアドレス、実行したい命令のアドレスが格納されたメモリのアドレスを指定すれば良いです。
libcリークは獲得領域のページがlibcに隣接することを使用して解いたのですが(localのubuntu)、Dockerも配布されなかったので本番環境だとちゃんとうまく行くのかは少し不安。

参考writeup

https://blog.rois.io/en/2021/rctf-2021-official-writeup/#musl

house of tataru(N1CTF 2021)

これもversion 1.2.2。 ほぼ先述のRCTFのやつとやることは同じでした。
seccompが有効で libcのhashは違うけどバージョンは一緒で例の便利なstack pivotが可能なgadgetがあるのでflagをorwします。

例のgadget

0x0007b1f5: mov rsp, qword [rdi+0x30] ; jmp qword [rdi+0x38] ;  (1 found)

参考writeup

N1CTF 2021 - house_of_tataru | kileak

babyheap 2021 (0CTF/TCTF 2021 Quals)

この問題だけバージョンが1.1.24でした。 すみません、まだ解き直していません。🙇‍♂️🙇‍♂️
解けたら追記するかもです。

まあすでにwriteupが出ているのでそちらを参考にした方が早いです。
ropのペイロード的にこれも前者と同じかも。

参考writeup

https://ptr-yudai.hatenablog.com/entry/2021/07/07/125444#pwn-392pts-BabyHeap-2021-18-solves

まとめ

いざとき直してみると、そんなに手法にバリエーションはなかった印象です。(3問しか解いてない)
今のところchunkの管理手法、meta領域のunlink attack、FSOPができれば対応できそうな雰囲気。
個人的な感想は管理方法は違えどglibcのheapガチャガチャと感覚はほぼ一緒なので数問解いておけばこれから出題されても頑張れそう。

来年もよろしくお願いします。