コース目次 / 第3章

コマンドインジェクション — 入力が、コマンドになる

入力をシェルコマンドに連結する処理に、区切り記号で別コマンドを注入します。原因を理解し、そもそもシェルを介さない・引数配列で渡すという直しで、再検証します。

第3章 / 全6章目安 約12分この章のゴール: コマンドインジェクションを起こし、シェルを介さない設計で直し、再検証する

次は、入力が OS のシェルコマンドとして解釈される穴です。SQL インジェクションと双子のような穴で、直しの発想もそっくり——データとコードを混ぜない。

法と倫理ゲート

手を動かす前に

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

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

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

標的 — シェルであいさつを作る(悪い例)

「入力された名前であいさつ文を作る」だけの機能を、わざとシェル経由で書いた例です。vuln-greet.js として保存します。

// vuln-greet.js — 学習用「わざと穴を空けた」あいさつ生成。127.0.0.1 だけで動かす。
// 狙う穴: コマンドインジェクション(入力をシェルコマンドに連結している)。
const http = require('node:http');
const { exec } = require('node:child_process');

const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1:3400');

  if (url.pathname === '/greet') {
    const name = url.searchParams.get('name') || 'ゲスト';
    // 【穴: コマンドインジェクション】入力を、そのままシェルに渡す文字列へ連結。
    // exec はシェル経由で実行するので、区切り記号が「効いて」しまう。
    exec('echo こんにちは、' + name, (err, stdout, stderr) => {
      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
      res.end('出力:\n' + stdout + (stderr ? 'エラー:\n' + stderr : ''));
    });
    return;
  }

  res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
  res.end('例: /greet?name=はると');
});

server.listen(3400, '127.0.0.1', () => console.log('やられあいさつ: http://127.0.0.1:3400 (Ctrl+C で停止)'));
node vuln-greet.js

正規の動きを確かめます。

http://127.0.0.1:3400/greet?name=はると

出力: こんにちは、はると と返ります。サーバは内部で echo こんにちは、はると というコマンドをシェルで実行しています。

攻撃 — 別のコマンドを、くっつける

シェルは、&& や ; のような区切り記号で複数のコマンドをつなげられます。名前欄にそれを混ぜたら?

http://127.0.0.1:3400/greet?name=はると && whoami

サーバがシェルに渡した文字列は、こう化けます:
echo こんにちは、はると && whoami
シェルは && を「前が成功したら次も実行」と解釈し、echo に続けて whoami(いまのユーザー名を表示するコマンド)を実行します。出力に、あいさつと一緒にあなたのユーザー名が出たはずです。whoami が動いたということは——任意のコマンドを、サーバ上で実行できるということ。これがコマンドインジェクションで、注入の中でも特に危険(サーバそのものを乗っ取られうる)です。

区切り記号は OS で少し違います。&& は Mac・Linux・Windows のどのシェルでも効きます。Mac・Linux ではさらに ;(前の成否に関係なく次を実行)も使えます:?name=はると; whoami。

修正 — そもそも、シェルを介さない

いちばん素直な直しは、「そもそもシェルを使わない」ことです。あいさつ文を作るのに、外部コマンドは要りませんでした。文字列は JavaScript の中で組み立てれば済みます。

// 直したあと(本命): 外部コマンドを使わず、文字列は自分で組み立てる。
const greeting = 'こんにちは、' + name;
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('出力:\n' + greeting);

「その処理に、本当にシェルが要るのか?」——コマンドインジェクションの多くは、要らないのにシェルを呼んでいたことが原因です。まず、外部コマンド自体を無くせないかを疑う。

では、どうしても外部プログラムを動かす必要があるとき(画像変換など)は? そのときも、SQL のプレースホルダと同じ発想を使います。

シェルを介す exec ではなく、execFile / spawn にプログラム名と引数の配列を渡す。たとえば execFile(‘convert’, [inputPath, outputPath]) のように。こうすると、引数はシェルを通らず、プログラムに「データ」として直接渡る。&& や ; を含んでいても、それは1個の引数の中身にすぎず、コマンドの区切りとして解釈されません。
データ(引数)とコード(コマンド)を、別のルートで渡す——SQL のプレースホルダと、まったく同じ原理です。

再検証 — もう一度、&& whoami を

「シェルを使わない」版に直してサーバを再起動し、さっきの攻撃をもう一度。

http://127.0.0.1:3400/greet?name=はると && whoami

今度は whoami は実行されず、出力: こんにちは、はると && whoami と、入力が丸ごと文字としてあいさつに入るだけです。&& はただの文字になりました。注入する先(シェル)が無くなったので、注入も成立しません。

持ち帰る一言

まずシェルを疑い、要るなら引数配列で。 コマンドインジェクションの直しは、①そもそもシェルを介さない、②必要なら execFile/spawn に引数配列で渡す。SQL のプレースホルダと同じ「データとコードを分ける」発想です。次は、入力がテンプレートの式になる SSTI と、パス越境の復習です。

こうなっていればOK

卒業まであと2章です。

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