わざと脆弱にしてあるアプリです。動かすのは自分のPCのlocalhostだけにして、公開サーバには置かないでください。他人のサービスに同じことを試すのも当然NGです。
なんで作ったか
順当に最近の脆弱性突かれた攻撃に対して少しでも知見を深めるというのが大きいです。
あと、脆弱性の勉強って、本を読んで「なるほど」と思っても、自分で手を動かさないとすぐ忘れるんですよね。
SQLインジェクションも、' OR '1'='1 を自分で打ち込んで全件出てきたときに初めて「あ、こういうことか」となった記憶があります。
練習用のやられアプリとしては徳丸浩さんのPHP製BadTodoが有名ですが、普段の仕事はTypeScript + Reactが多いので、教育用も兼ねて同じような構成で触れるものがほしくなりました。
JWTとかCORSとか、SPAだからこそ踏みやすい穴もあるので。
というわけで、BadTodoを参考にTypeScriptで作り直したのがこれです。
中身
ただのToDoアプリです。ログインして、タスクを作って、コメントして、ファイルを添付して、管理者はユーザーを管理できる。それだけ。
その普通の機能のあちこちに、OWASP Top 10に沿った脆弱性を仕込んであります。
構成はこんな感じです。
- バックエンド: Node.js + Express
- フロント: React + Vite
- DB: SQLite(better-sqlite3)
- テスト: Vitest + supertest
npm workspacesで packages/server と packages/web に分けています。
動かし方
git clone https://github.com/thithi7110/badtodo-ts.git
cd badtodo-ts
npm install
npm run dev
http://127.0.0.1:5173 を開けば画面が出ます。初回起動時にダミーデータが入るので、admin / password123、alice / alice、bob / bob のどれかでログインしてください。
サーバは 127.0.0.1 にしかbindしないようにしてあるので、同じLANの別マシンからは見えません。うっかり穴だらけのアプリを社内ネットワークに晒す、みたいな事故は避けたかったので。
一番やりたかったこと: 安全モードの切り替え
画面右上にスイッチがあって、これをONにすると対策済みのAPI(/api/secure/...)に切り替わります。
同じ画面で同じ入力をして、OFFだと攻撃が通る、ONだと通らない、を見比べられるようにしました。
たとえばタスク検索にこれを入れると、
') UNION SELECT id, 0, username, password, email, 0, 0, role FROM users --
安全モードOFFだと、自分のタスクに混じってユーザー名とパスワードハッシュが並びます。
ONにすると、ただの検索文字列として扱われて0件です。
サーバのコードも src/routes/(脆弱版)と src/secure/(対策版)でファイルを分けているので、diffを取れば何を直したのかが一目で分かります。
XSSも
コメント欄にHTMLを書くと、それを見た人のブラウザでそのまま実行されます。定番の alert だとスクショに写らないので、Cookieを画面に書き出すようにしてみました。
<img src=x onerror="this.insertAdjacentHTML('afterend','<b style=color:red>XSS発火: '+document.cookie+'</b>')">
セッションCookieに HttpOnly が付いていないので、document.cookie から sid がそのまま読めてしまいます。これを外部に送れば、なりすましができてしまいます。
Reactは基本的にエスケープしてくれるので、普通に書いていればXSSはなかなか起きません。わざと起こすために dangerouslySetInnerHTML を使っています。
export function UserHtml({ html, className }: { html: string; className?: string }) {
if (isSecureMode()) return <div className={className}>{html}</div>;
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}
作っていて思ったのは、Reactでも穴は普通に残るということです。APIのエラーを text/html で返していたり、アップロードされたHTMLを同じオリジンで配信していたりすると、Reactの外側から刺さります。どちらもこのアプリに入れてあります。
入れてある脆弱性
全部で30個くらいです。主なものだけ挙げます。
| OWASP | 中身 |
|---|---|
| A01 アクセス制御 | IDOR、管理APIの認可漏れ、パストラバーサル |
| A02 暗号 | MD5でのパスワード保存、JWTの署名鍵が secret
|
| A03 インジェクション | SQLi、XSS(格納型・反射型・DOM)、OSコマンド |
| A04 設計 | ログイン試行回数の制限なし、4桁のパスワード再設定トークン |
| A05 設定ミス | スタックトレースがそのまま返る、CORS全許可 |
| A07 認証 | セッション固定、ログアウトしてもセッションが生きてる |
| A08 整合性 | JWTの alg:none を受け入れる |
| A09 ログ | パスワードが平文でログに出る |
| A10 SSRF | URLプレビューで内部サービスに届く |
| その他 | CSRF、オープンリダイレクト、マスアサインメント |
一覧は spec/vulnerabilities.md にあります。
SSRFは外に飛ばすわけにいかないので、クラウドのメタデータサーバっぽい応答を返すダミーを 127.0.0.1:3001 に立てています。URLプレビューに http://127.0.0.1:3001/latest/meta-data/ を入れると中身が返ってくるので、そこから辿っていくと認証情報(もちろんダミー)まで行き着きます。
個別の脆弱性を組み合わせるのも面白いです。マスアサインメントで自分のroleを admin に書き換えて、そのまま管理画面でほかのユーザーを消す、とか。
コードのどこが悪いか
仕込んだ箇所には全部 // VULN: コメントを付けています。
// VULN: INTEGRITY-AlgNone - ヘッダの alg を信用し、"none" なら署名検証をスキップする
if (String(header.alg).toLowerCase() === 'none') return payload;
先に自分で探してから grep -rn "VULN:" で答え合わせ、という遊び方もできます。
docs/textbook/ には脆弱性ごとに、なぜ起きるのか、どう再現するのか、どう直すのかを書いています。
テストは「攻撃が通ること」を確かめる
ここはちょっと変わっていて、テストで確かめているのは「脆弱版で攻撃が成功すること」と「安全版で防げること」の両方です。
it('脆弱版: UNION でユーザー表(パスワードハッシュ)が漏えいする', async () => {
const { agent } = await login(app, 'bob', 'bob');
const res = await agent.get('/api/tasks').query({ q: payload });
const leaked = res.body.find((r: { title: string }) => r.title === 'admin');
expect(leaked.body).toMatch(/^[0-9a-f]{32}$/);
});
it('安全版: 検索語として扱われ、何も漏れない', async () => {
const { agent } = await login(app, 'bob', 'bob', '/api/secure');
const res = await agent.get('/api/secure/tasks').query({ q: payload });
expect(res.body).toEqual([]);
});
リファクタリングしたら脆弱性がうっかり直っていた、というのを防ぐためです。やられアプリならではの悩みですね。
テスト名に「脆弱版」「安全版」と書いておくと、実行結果がそのまま脆弱性の一覧っぽくなります。
作っていて困ったこと
CSRFがなかなか再現できませんでした。
罠ページからfetchでPOSTしても、Cookieが付かない。最近のChromeは SameSite が未指定のCookieを Lax として扱うので、別サイトからのfetchにはCookieが乗らないんですね。
やられアプリなのにブラウザに守られてしまう、という状態です。結局、トップレベルのフォームPOSTを使うか、同じサイトの別ポートに罠ページを置いて再現する方法を教科書に書きました。
あと、壊すより直すほうがずっと難しかったです。SSRFの対策版は、スキームを制限して、ドメインを許可リストで絞って、プライベートIPを弾いて、リダイレクトを追わないようにして……と、一つ塞いでもまだ別の抜け道がある感じでした。
おわりに
今後はCTFっぽい課題集や、docker-composeでの起動にも対応したいと思っています。
「これも入れてほしい」「説明が間違ってる」などあれば、IssueかPRでもらえると嬉しいです。
参考
- OWASP Top 10:2021
- 徳丸浩『体系的に学ぶ 安全なWebアプリケーションの作り方 第2版』





