コース目次 / 第1章
水平の穴 — 他人の資源に、手が届く
ユーザーとロールと注文を持つ自作ポータルを立て、同じ立場の他人の注文をID指定で読み出します。二アカウント法で水平の穴(IDOR/BOLA)を体系的に探す手順を身につけます。
標的を立てます。ユーザー(架空太郎・架空花子・管理者)と、それぞれの注文を持つ、小さなポータルです。認可の2つの穴(水平・垂直)を、わざと空けてあります。この章では水平の穴を扱います。
法と倫理ゲート
手を動かす前に
このコースは攻撃の技術に触れます。実習してよい相手は、次の3つだけです。
- (a) 自分で作った練習用のやられ環境(このサイトが配る自作ターゲット)
- (b) あなた自身の許可された環境(自分の Thousand SKY・secret-notes-ctf など)
- (c) CTF 競技の中(規約の範囲で)
実在する第三者のシステムを許可なく触ることは、不正アクセス禁止法に触れます。 このゲートは、その線引きを毎回の入口で確かめるためのものです。詳しくは「セキュリティの法と倫理」へ。
標的 — 認可を壊したポータル
下を vuln-portal.js として保存し、起動します。
// vuln-portal.js — 学習用「わざと認可を壊した」ポータル。127.0.0.1 だけで動かす。
const http = require('node:http');
const USERS = {
t1: { name: '架空太郎', role: 'user' },
t2: { name: '架空花子', role: 'user' },
admin: { name: '管理者', role: 'admin' },
};
const ORDERS = [
{ id: 1001, owner: 't1', item: '三脚', amount: 8000 },
{ id: 1002, owner: 't2', item: 'フィルム', amount: 3000 },
];
function currentUid(req) {
const m = /(?:^|;\s*)uid=([^;]+)/.exec(req.headers.cookie || '');
return m && USERS[m[1]] ? m[1] : null;
}
function send(res, status, obj) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(obj, null, 2));
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:3600');
const uid = currentUid(req);
if (url.pathname === '/login') {
const as = url.searchParams.get('as') || 't1';
if (!USERS[as]) { send(res, 400, { error: '不明なユーザー' }); return; }
res.writeHead(302, { 'Set-Cookie': 'uid=' + as + '; Path=/', Location: '/whoami' });
res.end();
return;
}
if (url.pathname === '/whoami') {
send(res, 200, uid ? { uid, ...USERS[uid] } : { uid: null });
return;
}
if (url.pathname === '/orders') { // 自分の注文一覧
if (!uid) { send(res, 401, { error: 'ログインしてください' }); return; }
send(res, 200, ORDERS.filter((o) => o.owner === uid));
return;
}
const m = /^\/orders\/(\d+)$/.exec(url.pathname); // 注文の詳細
if (m) {
if (!uid) { send(res, 401, { error: 'ログインしてください' }); return; }
const order = ORDERS.find((o) => o.id === Number(m[1]));
if (!order) { send(res, 404, { error: 'ありません' }); return; }
// 【穴1: 水平 / IDOR・BOLA】この注文が uid のものか、確かめていない。
send(res, 200, order);
return;
}
if (url.pathname === '/admin/users') { // 管理: 全ユーザー
if (!uid) { send(res, 401, { error: 'ログインしてください' }); return; }
// 【穴2: 垂直 / BFLA】uid が管理者ロールか、確かめていない(第2章で扱う)。
send(res, 200, Object.entries(USERS).map(([id, u]) => ({ id, ...u })));
return;
}
send(res, 200, { hint: '/login?as=t1 → /orders → /orders/1001 → /admin/users' });
});
server.listen(3600, '127.0.0.1', () => console.log('やられポータル: http://127.0.0.1:3600 (Ctrl+C で停止)'));
node vuln-portal.js
正規の動きを、掴む
まず架空太郎でログインし、自分の注文を見ます。
http://127.0.0.1:3600/login?as=t1
http://127.0.0.1:3600/orders
/orders には、太郎の注文 1001(三脚)だけが並びます。一覧は owner === uid で正しく絞られている。ここまでは、まっとうに見えます。
攻撃 — 隣の ID を、試す
一覧に出るのは自分の注文だけ。では、一覧に出ない番号を直接叩いたら? 太郎の注文が 1001 なら、隣は?
http://127.0.0.1:3600/orders/1002
架空太郎のまま、花子の注文 1002(フィルム・3000円)が見えてしまいました。一覧では owner === uid で絞っていたのに、詳細(/orders/:id)では所有者チェックを忘れている。この「一覧は絞るのに、個別取得で確認を落とす」ちぐはぐが、IDOR / BOLA の典型パターンです。注文の中身は、金額や商品——本物なら、他人の購買履歴が丸見えです。
体系的に探す — 二アカウント法
一つ見つけて終わりにせず、認可の穴を手順化します。診断で最も使うのが二アカウント法です。
1. 2つのアカウントを用意する。このポータルなら架空太郎(t1)と花子(t2)。
2. 一方(太郎)で、自分の資源の ID を控える。太郎の注文は 1001。
3. 太郎のセッションのまま、もう一方(花子)の資源 ID を叩く。花子の注文 1002 を、太郎で開く。
4. 見えたら穴。本来は 403 が返るべき場所で 200 が返れば、水平の権限昇格です。
連番 ID(1001, 1002, 1003…)なら、隣を試すだけ。UUID のような当てにくい ID でも、「花子のセッションで得た自分の ID を、太郎のセッションで叩く」と確実に試せます。2人分のセッションを用意して突き合わせる——これが認可診断の基本動作です。
Burp を使うなら、太郎のリクエストを Repeater に送り、id を 1002 に変えて Send(sec-burp の「一字変えて送り直す」)。Cookie を花子のものに差し替えて比べる、といった操作も楽になります。
持ち帰る一言
一覧が絞れていても、個別取得を疑う。 水平の穴(IDOR / BOLA)は「同じ立場の他人の資源に手が届く」こと。二アカウント法——自分の ID を基準に、他人の ID を自分のセッションで叩く——で体系的に探せます。次は、もう一つの軸——上位権限に手が届く垂直の穴です。
こうなっていればOK
卒業まであと3章です。
この章はまだ完了していません。
保存できませんでした(プライベートブラウズ中かもしれません)。この端末に進捗は残りませんが、先へは進めます。