コース目次 / 第2章
SQL インジェクション — 入力が、SQL になる
文字列連結で組んだログインクエリに ' OR を注入し、パスワードなしでログインを突破します。原因を理解し、プレースホルダ(パラメータ化)で直し、同じ攻撃で再検証します。
次は、入力が SQL として解釈される穴です。データベースに問い合わせる Web アプリで、最も古典的で最も危険な注入。ログイン画面を題材に、パスワードなしで突破してみます。
この章の標的は Node の node:sqlite を使います。外部ライブラリは入れませんが、Node.js 22.5 以降が必要です(node –version で確認)。実験的機能のため、起動は node –no-warnings vuln-login.js のように –no-warnings を付けると警告が消えて見やすくなります。お使いの Node が古い場合は、コードと解説を読んで理解する形で進めてください——穴の形と直し方は、どのDBでも同じです。
法と倫理ゲート
手を動かす前に
このコースは攻撃の技術に触れます。実習してよい相手は、次の3つだけです。
- (a) 自分で作った練習用のやられ環境(このサイトが配る自作ターゲット)
- (b) あなた自身の許可された環境(自分の Thousand SKY・secret-notes-ctf など)
- (c) CTF 競技の中(規約の範囲で)
実在する第三者のシステムを許可なく触ることは、不正アクセス禁止法に触れます。 このゲートは、その線引きを毎回の入口で確かめるためのものです。詳しくは「セキュリティの法と倫理」へ。
標的 — 自作ログイン
下を vuln-login.js として保存します。メモリ上の小さなユーザーテーブルに対して、名前とパスワードで認証する最小のログインです。
// vuln-login.js — 学習用「わざと穴を空けた」ログイン。127.0.0.1 だけで動かす。
// 狙う穴: SQLインジェクション。要 Node 22.5+(node:sqlite)。起動: node --no-warnings vuln-login.js
const http = require('node:http');
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(':memory:');
db.exec("CREATE TABLE users (id INTEGER, name TEXT, password TEXT)");
db.exec("INSERT INTO users VALUES (1, '架空太郎', 'haru-pass'), (2, '架空花子', 'hana-pass')");
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:3200');
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
if (url.pathname === '/login') {
const name = url.searchParams.get('name') || '';
const pw = url.searchParams.get('pw') || '';
// 【穴: SQLインジェクション】入力を、そのまま SQL 文に文字列連結している。
const sql = "SELECT * FROM users WHERE name='" + name + "' AND password='" + pw + "'";
let row;
try {
row = db.prepare(sql).get();
} catch (e) {
res.end('SQLエラー: ' + e.message);
return;
}
res.end(row ? 'ようこそ、' + row.name + ' さん(ログイン成功)' : 'ログイン失敗');
return;
}
res.end('ログイン例: /login?name=架空太郎&pw=haru-pass');
});
server.listen(3200, '127.0.0.1', () => console.log('やられログイン: http://127.0.0.1:3200 (Ctrl+C で停止)'));
node --no-warnings vuln-login.js
正しいログインを確かめます。
http://127.0.0.1:3200/login?name=架空太郎&pw=haru-pass
ようこそ、架空太郎 さん(ログイン成功)。間違ったパスワードなら ログイン失敗。ここまでが正規の動きです。
攻撃 — パスワードを、消し去る
いま、サーバの中ではこういう SQL が組まれています。
SELECT * FROM users WHERE name='架空太郎' AND password='haru-pass'
ここに、name として次を入れたらどうなるか。SQL のコメント記号 -- で、パスワード判定を丸ごと消してしまいます。
http://127.0.0.1:3200/login?name=架空太郎'--%20&pw=なんでもいい
name の中の ’ が名前の文字列を閉じ、– 以降が SQL のコメントになります。サーバが組んだ文は、こう化けます:
SELECT * FROM users WHERE name=‘架空太郎’– ’ AND password=‘…’
– から後ろ(パスワード判定)はコメントとして無視され、「名前が架空太郎」だけで通ってしまう。パスワードを知らなくても、架空太郎としてログイン成功。あなたの入力が、データではなく SQL の文法(コメント)として解釈された——これが SQL インジェクションです。
さらに name を ' OR 1=1 -- にすれば、条件が常に真になり、テーブルの先頭のユーザーとして入れてしまいます。入力ひとつで、認証が意味をなさなくなる。これが最も危険と言われるゆえんです。
修正 — データとコードを、分ける
原因は、入力を SQL 文に文字列連結していること。直しは、注入対策の本命——プレースホルダ(パラメータ化クエリ)です。SQL の骨格に ? という穴を空けておき、値はあとから別ルートで流し込みます。
// 直したあと: 値は文字列連結せず、? のプレースホルダにバインドする。
const row = db.prepare('SELECT * FROM users WHERE name = ? AND password = ?').get(name, pw);
? に .get(name, pw) で渡した値は、データベースが「これはデータであって、SQL 文の一部ではない」と扱います。だから name に ‘– が入っていても、それは「’– という名前のユーザーを探す」という意味になるだけ。コメントにも、条件にもならない。
これが注入対策の芯です——データとコードを、最初から別のルートで渡す。連結して「あとから危ない文字を消す」のではなく、そもそも混ざらない仕組みに乗せる。SQL でも、次章のコマンドでも、原理は同じです。
再検証 — もう一度、’– を
サーバを再起動して、さっきの攻撃をもう一度。
http://127.0.0.1:3200/login?name=架空太郎'--%20&pw=なんでもいい
今度は ログイン失敗。架空太郎'-- という名前のユーザーは存在しないので、当然です。' OR 1=1 -- も同じく失敗します。正規のログイン(name=架空太郎&pw=haru-pass)がちゃんと通ることも確かめます。注入が、原理的に消えました。
持ち帰る一言
SQL インジェクションの直しは、プレースホルダ一択。 入力を SQL 文に連結するのをやめ、? で骨格を固定して値をバインドする。「危ない文字を消す」より「そもそも混ざらない仕組み」。この「データとコードを分ける」発想を、次はシェルコマンドに当てはめます。
こうなっていればOK
卒業まであと3章です。
この章はまだ完了していません。
保存できませんでした(プライベートブラウズ中かもしれません)。この端末に進捗は残りませんが、先へは進めます。