コース目次 / 第1章

Introspection — 地図そのものが、漏れる

自作GraphQLサーバに__schemaクエリを送り、全フィールド・全ミューテーションが丸見えになる様子を確認します。introspectionがなぜ攻撃の下見に使われるかを理解します。

第1章 / 全6章目安 約10分この章のゴール: introspectionクエリでスキーマ全体が開示される様子を確認し、その危うさを理解する

GraphQL には、「このAPIが持つ、すべての型・フィールド・引数を問い合わせる」という標準機能があります——introspection(内省)です。開発中はとても便利ですが、本番でそのまま開けていると、攻撃者に完全な地図を渡すことになります。

法と倫理ゲート

手を動かす前に

このコースは攻撃の技術に触れます。実習してよい相手は、次の3つだけです。

  • (a) 自分で作った練習用のやられ環境(このサイトが配る自作ターゲット)
  • (b) あなた自身の許可された環境(自分の Thousand SKY・secret-notes-ctf など)
  • (c) CTF 競技の中(規約の範囲で)

実在する第三者のシステムを許可なく触ることは、不正アクセス禁止法に触れます。 このゲートは、その線引きを毎回の入口で確かめるためのものです。詳しくは「セキュリティの法と倫理」へ。

標的を立てる

vuln-api.js として保存します。ユーザーとメモを持つ、小さな GraphQL API です。

// vuln-api.js — 学習用GraphQL API。127.0.0.1:4200。X-UID ヘッダで「いまの利用者」を表す。
const http = require('node:http');
const { graphql, buildSchema } = require('graphql');

const schema = buildSchema(`
  type Query {
    me: User
    note(id: Int!): Note
  }
  type Mutation {
    updateProfile(input: ProfileInput!): User
    deleteUser(id: Int!): Boolean
  }
  input ProfileInput { name: String, bio: String, role: String }
  type User { id: Int, name: String, role: String }
  type Note { id: Int, owner: Int, text: String }
`);

const users = { 1: { id: 1, name: '架空太郎', role: 'user' }, 2: { id: 2, name: '架空花子', role: 'user' } };
const notes = { 101: { id: 101, owner: 1, text: '太郎のメモ' }, 102: { id: 102, owner: 2, text: '花子の非公開メモ' } };

function rootFor(uid) {
  return {
    me: () => users[uid],
    note: ({ id }) => notes[id], // 【穴: BOLA。第2章で扱う】
    updateProfile: ({ input }) => { Object.assign(users[uid], input); return users[uid]; }, // 【穴: mass assignment。第3章】
    deleteUser: ({ id }) => { delete users[id]; return true; }, // 【穴: BFLA。第3章】
  };
}

http.createServer((req, res) => {
  let body = '';
  req.on('data', (c) => (body += c));
  req.on('end', async () => {
    const uid = Number(req.headers['x-uid'] || 1);
    const { query, variables } = JSON.parse(body);
    const result = await graphql({ schema, source: query, rootValue: rootFor(uid), variableValues: variables });
    res.setHeader('Content-Type', 'application/json; charset=utf-8');
    res.end(JSON.stringify(result));
  });
}).listen(4200, '127.0.0.1', () => console.log('やられGraphQL API: http://127.0.0.1:4200 (Ctrl+C で停止)'));
node vuln-api.js

正規の使い方

自分の情報を問い合わせます。

curl -s -X POST -H "Content-Type: application/json" \
  -d '{"query":"{ me { id name role } }"}' http://127.0.0.1:4200/
{"data":{"me":{"id":1,"name":"架空太郎","role":"user"}}}

攻撃 — スキーマを、丸ごと問い合わせる

__schema という特殊なフィールドに問い合わせると、API の全体構造が返ってきます。

curl -s -X POST -H "Content-Type: application/json" \
  -d '{"query":"{ __schema { types { name fields { name } } } }"}' \
  http://127.0.0.1:4200/

返ってくる JSON には、Query 型の全フィールド(me、note)だけでなく、Mutation 型の updateProfile や deleteUser まで、丸ごと並びます。画面のどこにも「削除ボタン」が無くても、このクエリ一発で「deleteUser というミューテーションが存在する」ことが攻撃者に知られます。sec-authz の「フォースブラウジング」(隠しURLを推測して叩く)を、GraphQL は推測すら要らず、聞けば教えてくれる形にしてしまいます。

なぜ、これが偵察になるのか

攻撃の第一歩は、いつも「何があるか知る」ことでした(sec-report の偵察を思い出してください)。introspection は、その偵察を1回の問い合わせで終わらせてしまいます。型名・フィールド名・引数の型・必須かどうかまで、すべて分かる。
これは、REST の API ドキュメントを公開しているのと同じことです——開発中は便利で、社内向けなら問題ないこともありますが、公開する本番 API では、意図せず内部構造を晒すことになりがちです。多くの GraphQL サーバフレームワークは、本番環境向けに introspection を無効化するオプションを用意しています。

持ち帰る一言

introspectionは、地図そのものを渡す。 __schema クエリ1本で、隠しておきたいミューテーションの存在まで含めた全体構造が分かってしまう。本番では基本的に無効化する対象です。次は、この地図で見つけた note フィールドを、実際に攻撃します——BOLA です。

こうなっていればOK

卒業まであと4章です。

この章はまだ完了していません。