コース目次 / 第2章
SSRF の修正 — 許可リストと、内部IPの遮断
URLフェッチ機能に、宛先ホストの許可リストと、ループバック・プライベートIP帯の遮断を入れて直します。同じ攻撃で再検証し、なぜブラックリストでは不十分かを理解します。
前章で開けた穴を、public-api.js に手を入れて塞ぎます。狙いは2つ——許可された宛先だけを通すことと、内部向けのIPを機械的に遮断することです。
法と倫理ゲート
手を動かす前に
このコースは攻撃の技術に触れます。実習してよい相手は、次の3つだけです。
- (a) 自分で作った練習用のやられ環境(このサイトが配る自作ターゲット)
- (b) あなた自身の許可された環境(自分の Thousand SKY・secret-notes-ctf など)
- (c) CTF 競技の中(規約の範囲で)
実在する第三者のシステムを許可なく触ることは、不正アクセス禁止法に触れます。 このゲートは、その線引きを毎回の入口で確かめるためのものです。詳しくは「セキュリティの法と倫理」へ。
直し1 — 許可リストで、ホストを絞る
このコースの一貫した原則、許可リストです。画像取得なら、信頼する画像配信元だけを許します。
// 直したあと(1): 許可したホストだけを通す。
const ALLOWED_HOSTS = new Set(['example.com', 'example.org']);
function isAllowedHost(urlStr) {
let u;
try { u = new URL(urlStr); } catch { return false; }
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false; // file: 等を拒否
return ALLOWED_HOSTS.has(u.hostname);
}
直し2 — 内部向けのIPを、機械的に遮断する
許可リストに載っているホスト名でも、実は罠が残っています。ホスト名を名前解決した実際のIPが、内部向け(ループバックやプライベート帯)でないかまで確かめる必要があります。
なぜホスト名の許可リストだけでは足りないか。DNS のリバインディングという手口があるからです——攻撃者が用意したドメインが、最初は無害なIPを返し、時間差で127.0.0.1のような内部IPに差し替わる。ホスト名の検証をすり抜けて、実際の接続は内部へ向かう。
だから、実際に解決されたIPアドレス自体を、ループバック(127.0.0.1・::1)やプライベート帯(10.0.0.0/8・172.16.0.0/12・192.168.0.0/16)、クラウドのメタデータ用アドレス(169.254.169.254)と照合して、該当すれば接続そのものを拒否します。
// 直したあと(2): 名前解決した実IPが、内部向けでないか確かめる。
const dns = require('node:dns').promises;
const net = require('node:net');
function isPrivateOrLoopback(ip) {
if (ip === '127.0.0.1' || ip === '::1' || ip === '169.254.169.254') return true;
if (!net.isIPv4(ip)) return false;
const [a, b] = ip.split('.').map(Number);
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 169;
}
async function isSafeTarget(urlStr) {
if (!isAllowedHost(urlStr)) return false;
const { hostname } = new URL(urlStr);
const addrs = await dns.lookup(hostname, { all: true });
return addrs.every((a) => !isPrivateOrLoopback(a.address));
}
fetch-image のハンドラで、これを通してから取りに行きます。
if (url.pathname === '/fetch-image') {
const target = url.searchParams.get('url') || '';
isSafeTarget(target).then((safe) => {
if (!safe) { res.writeHead(400); res.end('許可されていない宛先です'); return; }
fetch(target).then((r) => r.text()).then((body) => {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('取得結果:\n' + body);
});
});
return;
}
再検証 — もう一度、内部APIへ
public-api.js を再起動して(Ctrl+C → node public-api.js)、前章とまったく同じ攻撃を試します。
curl "http://127.0.0.1:3900/fetch-image?url=http://127.0.0.1:3901/admin/config"
# => 400 許可されていない宛先です
127.0.0.1 は許可リストに無いホスト名の時点で拒否されますし、たとえ 127.0.0.1 を許可リストに紛れ込ませても、isPrivateOrLoopback がループバックとして遮断します。正規の使い方(url=https://example.com)は、引き続き通ります。
なぜ「弾く」ではなく「許す」なのか
最初にブラックリストの発想——「127.0.0.1 や localhost という文字列を見つけたら拒否」——を思いつくかもしれません。でも、これは迂回だらけです。127.1(短縮表記)、0x7f000001(16進)、0177.0.0.1(8進)——どれも実は同じ 127.0.0.1 を指しますが、文字列としては一致しません。
だから、文字列を見て弾くのではなく、実際に解決された IP アドレスの値そのものを見て、既知の内部帯に該当するかを判定します(isPrivateOrLoopback はまさにこれ)。パストラバーサルの path.resolve と同じ発想——表記ではなく、実体で判定する。
持ち帰る一言
許可した宛先だけ、しかも実IPで確認して。 SSRF の直しは、ホスト名の許可リストと、名前解決後の実IPでの内部帯遮断の二段構え。文字列のブラックリストは、表記の揺れで迂回されます。次は、もう一つのサーバ側の穴——復元が実行になってしまうデシリアライズです。
こうなっていればOK
卒業まであと2章です。
この章はまだ完了していません。
保存できませんでした(プライベートブラウズ中かもしれません)。この端末に進捗は残りませんが、先へは進めます。