CSTI(Client Side Template Injection)
CSTI 란?
사용자 입력이 클라이언트에서 평가되는 템플릿에 주입되어, 클라이언트 측 템플릿 엔진이 그걸 표현식으로 실행하게 만드는 공격.
클라이언트 측 템플릿 프레임워크를 사용하는 애플리케이션이 웹 페이지에 사용자 입력을 동적으로 삽입할 때 발생한다. 페이지 렌더링 시 프레임워크가 악의적인 입력값으로 생성한 표현식을 실행하면서, 크로스 사이트 스크립팅(XSS) 공격이 가능해진다.
CSTI 실습
CSTI 사례 중 가장 유명한 AngularJS 샌드박스 우회를 간단히 실습해봄. html 페이지만 만들어보면 돼서 진짜 간단하긴 했는데…
우선 AngularJS 샌드박스가 어떤 기능인지, 어떻게 우회하는 건지 정리하면 아래와 같다.
AngularJS 샌드박스의 작동 원리
AngularJS란?
프론트엔드 자바스크립트 프레임워크로, {{ }} 안에 표현식을 넣으면 그 값을 자동으로 화면에 렌더링해주는 데이터 바인딩이 핵심 기능이다. 버전이 0.xx 또는 1.xx인게 AngularJS이고, 버전 2 이후로는 Angular로 이름이 바뀜.
AngularJS 샌드박스는 AngularJS 템플릿 표현식 안에서 위험한 객체나 속성에 접근하지 못하게 막는 장치. 초기엔 우회가 까다로웠지만 보안 연구자들이 계속 우회법을 찾아내서 1.6 버전부터는 샌드박스 자체가 제거됨. 그러나 아직 구버전 AngularJS를 쓰는 앱들이 있어서 취약점으로 남아 있다고 한다.
AngularJS 샌드박스 동작 흐름
샌드박스 동작흐름은
표현식 파싱 > 자바스크립트로 다시 씀 > 재작성된 코드에 위험한 객체 있는지 여러 검사 함수로 테스트 의 크게 3단계로 나뉘어진다.
주요 검사 함수들:
ensureSafeObject(): 어떤 객체가 자기 자신을 참조하는지 검사함. 이게 window 객체를 탐지하는 방법 중 하나. Function 생성자도 비슷한 방식으로 잡는데, constructor 속성이 자기 자신을 참조하는지를 보고 걸러냄.
ensureSafeMemberName(): 객체의 속성 접근을 하나하나 검사해서, proto__나 __lookupGetter 같은 위험한 속성이 있으면 차단.
ensureSafeFunction(): call(), apply(), bind(), constructor()가 호출되는 걸 막음.
window객체, Function 생성자, proto 속성 등이 위험한 이유
안전한 값에서 출발해 전역 위험 객체(임의 코드 실행)로 도달하거나, 공유 객체를 오염시켜 앱을 조작할 수 있는 통로로 활용될 수 있기 때문.
예를 들어 자바스크립트에서 모든 객체는 constructor로 자기를 만든 함수에 접근할 수 있는데,
1
2
"".constructor // → String
"".constructor.constructor // → Function. 얘가 핵심.
위의 코드에서 Function은 문자열을 진짜 코드로 만들어 실행하는 능력이 있다(eval과 같음). 따라서
1
"".constructor.constructor('alert(1)')() // 탈출 완료
alert(1)부분이 function 생성자에 닿을 수 있고, 샌드박스를 탈출할 수 있게 되는 것. 샌드박스가 alert, window 같은 위험한 단어를 아무리 막아도, Function(‘…‘)은 코드를 문자열 안에 숨겨서 검사를 통째로 우회하기 때문에 발생함…
이 때문에 ensureSafeFunction이 constructor() 호출을 막는다.
또 proto 속성은 객체의 프로토타입에 접근하는 속성인데, 두 가지 측면에서 위험하다고 한다.
첫째, __proto__를 타고 계속 올라가면 Object.prototype(모든 객체의 최상위 조상)에 닿고, 거기서 또 constructor 체인을 타면 Function으로 가는 우회 경로가 열림.
둘째, __proto__는 읽기뿐 아니라 쓰기도 됨:
1
{}.__proto__.isAdmin = true; // 이후 만들어지는 모든 객체가 오염됨
이러면 앱 전체 로직을 조작할 수 있는데, 이게 “프로토타입 오염(Prototype Pollution)”이라는 별도의 큰 취약점 클래스라고 한다고…
AngularJS 샌드박스 우회
샌드박스를 속여서, 악성 표현식을 무해한 것처럼 착각하게 만드는 것.
AngularJS는 표현식을 파싱할 때 isIdent() 함수(변수명에 쓸 수 있는 글자(a~z, A~Z, _, $)인지 판별하는 함수)로 문자를 한 글자씩 검사함.
1
2
3
4
5
6
isIdent = function(ch) {
return ('a' <= ch && ch <= 'z' || // 소문자
'A' <= ch && ch <= 'Z' || // 대문자
'_' === ch || // 언더스코어
ch === '$'); // 달러기호
}
정상적으로는 글자 하나(ch)를 받아서 검사하는데, charAt가 오염되면 isIdent에 문자열 전체가 통째로 넘어가게 된다.
charAt가 오염되었다 가정하고, 예를 들어 x9=9a9l9e9r9t9(919)라는 문자열이 isIdent로 들어가게 되면
1
isIdent('x9=9a9l9e9r9t9(919)')
이때 비교식 ‘a’ <= ch && ch <= ‘z’에서 ch는 문자열 전체지만, 자바스크립트는 문자열끼리 비교할 때 첫 글자부터 사전순으로 비교하게 되고,
1
2
'a' <= 'x9=9a...' // 첫 글자 'x'가 'a'보다 뒤 → true
'x9=9a...' <= 'z' // 첫 글자 'x'가 'z'보다 앞 → true
두 조건 다 true → isIdent가 true 반환. 맨 앞 글자 x만 보고 알파벳 소문자로 시작하니 안전한 변수명이네… 하고 통과시켜버리는 것. 뒤에 붙은 위험한 문자들은 검사 안 하고 문장 전체를 평범한 변수 이름으로 보고 넘어가게 된다.
실습 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>CSTI 테스트</title>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.8/angular.min.js"></script>
</head>
<body ng-app="csti_lab">
<h2>Search</h2>
<input type="text" id="search">
<button onclick="doSearch()">검색</button>
<div id="output"></div>
<script>
angular.module('csti_lab', []);
function doSearch() {
var val = document.getElementById('search').value;
var output = document.getElementById('output');
output.innerHTML = val;
var injector = angular.element(document.querySelector('[ng-app]')).injector();
injector.invoke(['$compile', '$rootScope', function($compile, $rootScope) {
$compile(output)($rootScope);
$rootScope.$apply();
}]);
}
</script>
</body>
</html>
AngularJS 1.5.8 버전 라이브러리를 사용하는 html 페이지이다.
간단하게 검색 기능만 있는데, 아래의 페이로드를 입력하여 reflected xss 공격이 되는 걸 볼 수 있다.
1
{{x={y:''.constructor.prototype};x.y.charAt=[].join;[1]|orderBy:'x=alert(1)'}}
이 페이로드는 크게 두 부분으로 쪼개 보면
1
2
3
4
5
{{
x = {y:''.constructor.prototype};
x.y.charAt = [].join; // (1) charAt 함수 오염
[1] | orderBy:'x=alert(1)' // (2) orderBy로 eval 트리거
}}
(2)에서 orderBy를 쓰는 이유는 약간 타이밍? 문제로 끼워맞춰진 건데…
charAt = [].join 오염은 표현식이 실행될 때 생기지만 isIdent 검사 자체는 파싱 단계에서 일어나므로,
검사 시점엔 아직 charAt가 안 망가진 상태라 $eval()(문자열을 다시 표현식으로 평가?하는 함수)나 orderBy 같은걸로 한 번 감싸줘야 함.
그러니까
x = {y:’‘.constructor.prototype}; x.y.charAt = [].join; 가 먼저 실행되어 charAt이 [].join으로 오염되고, [1] | orderBy:’x=alert(1)’가 실행되면서 ‘x=alert(1)’가 새로 파싱된다. 이 두번째로 파싱된 부분이 이미 charAt이 망가진 상태 → isIdent가 속아넘어감 → alert(1) 실행
의 과정을 거치는 것이다.
대응 방안
- 사용자 입력값으로 템플릿이나 표현식을 생성하지 않기 ★
사용자 입력이 템플릿 컴파일 대상이 된다는게 근본적인 원인이므로, 사용자 입력을 단순 데이터로만 다루도록, 템플릿 소스가 되게 하지 않도록 구성해야 한다.
- 사용자 입력을 템플릿에 넣기 전에 템플릿 표현식 문법({{ }} 등)을 필터링해서 제거하기.
주의: HTML 엔티티 변환 만으로는 CSTI를 막을 수 없음! 프레임워크가 템플릿을 실행하기 전에 디코딩해버리기 때문이다. 그래도 기본적인 방어선으로 적용하기.
- CSP (Content-Security-Policy) unsafe-eval을 금지하면 Function 생성자나 $eval 기반 우회 상당수가 막힌다는데, 이것도 우회가능함. 심층 방어를 위한 부분 중 하나로만 적용하기…?
참고자료
https://portswigger.net/web-security/cross-site-scripting/contexts/client-side-template-injection
https://aszx87410.github.io/beyond-xss/en/ch3/csti/#client-side-template-injection

