최근 수정 시각 : 2024-10-20 03:21:15

JSFuck

1. 개요2. 원리3. 예시
3.1. 인터넷 익스플로러용
4. 활용5. 여담

1. 개요

JS Fuck
홈페이지

JavaScript + BrainFuck = JSFuck

난해한 프로그래밍 언어가 아닌, 난해한 프로그래밍 스타일. JavaScript 코드를 JavaScript 문법에서 사용되는 문자 중 단 6가지인 [ ] ( ) ! +[1]만으로 구현할 수 있음에 착안하여 고안된 프로그래밍 스타일이다.

2. 원리

위 6가지 문자 중 단독으로 써서 멀쩡한 건 [] (어레이 리터럴) 뿐인데, 여기에서 모든 것을 시작하게 된다. 예를 들어 알파벳 문자열 "a" 는 다음의 과정에 의해 얻어오게 된다.
  • ![] = false
    배열 자체는 truthy한 값으로 인정되므로, 여기에 부정을 의미하는 !(느낌표)를 사용하게 되면 false가 나온다.
  • (객체)+[] = ""
    자바스크립트에서 오브젝트 간에 +(더하기) 연산은 정의되어 있지 않으므로 오브젝트끼리 더하게 되면 .toString()(문자열로 변환)[2] 처리가 되어 문자열 간의 더하기로 처리된다. 오브젝트로 취급되는 배열을 이용하여 [] + [] 를 하게 되면 [].toString() = ""이므로, 결과적으로 "" + "" = ""(빈 문자열)이 생성된다.
  • false + "" = "false"
    부울 값인 false에 빈 문자열을 더하게 되면 false.toString() = "false"이므로 "false" 라는 문자열이 나오게 된다. 앞서 확인한 내용을 조합해보면 ![]+([]+[]) = false + "" = "false"가 된다.
  • !![] = true
    false를 부정하면 true가 된다.
  • +true = 1
    true 앞에 unary plus를 쓰면 숫자형 1로 변환이 된다. # 앞서 얻어온 !![] = true의 앞에 + 를 넣은 +!![] 는 숫자 1이 된다.
  • "false"[1] = "a"
    앞서 얻어온 문자열 "false"의 두 번째 알파벳이 a이므로 "false"[1]"a"가 된다. 0부터 세는 것에 주의.
  • 지금까지 파악한 내용들을 모두 이으면 (![]+[])[+!![]] = (false + "")[+true] = "false"[1] = "a" 가 된다. 해냈다!!

이런 식으로 알파벳/숫자를 얻어오고, 각종 내장 오브젝트 등을 이용하여 함수 객체 등에 접근하여 함수를 만들고 또 호출하는 코드를 작성, 최종적으로 자바스크립트 프로그램을 작성하는 것이 가능하다. 실제로 위 홈페이지에 자바스크립트 코드를 입력하면 코드를 위의 6글자로 표현할 수 있도록 치환해서 JSFuck 코드를 작성한다. 간혹 위의 방법만으로는 표현할 수 없는 문자가 등장했을 경우 문자 코드를 이용하는 방법으로 어떻게든 회피하는 것을 볼 수 있다.

3. 예시

다음은 JSFuck 스타일로 작성된 alert(1) 호출 프로그램이다. 아래 텍스트를 복사하여 웹 브라우저의 개발자 도구 콘솔[3] 등에 붙여넣기를 하면 alert 창으로 1이 출력되는 것을 볼 수 있다. 코드 맨 뒤의 ()를 생략하고 개발자 도구 콘솔에 아래 코드를 입력하게 되면 함수가 만들어진 것을 확인할 수 있다.

#!syntax javascript
[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[
]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]][([][(![]+[])[+[]]+([![]]+[][[]
])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+
(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+
!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![
]+[])[+!+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]
+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+([![]]+[][[]])[
+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!!
[]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+([![
]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[
]+!+[]]+(!![]+[])[+!+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]((![]+[])[+!+[]]+(![
]+[])[!+[]+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]+(!![]+[])[+[]]+(!
[]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])
[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]]+[+!+[]]+(
!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[
])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]])()


무려 1,227글자에 달하는 거대한 코드...인 것 같지만, 사실 잘 뜯어보면 []["filter"]["constructor"]("alert(1)")()로 줄어드는 것을 알 수 있고, 이는 [].filter.constructor("alert(1)")() = Function("alert(1)")()으로 해석되어 최종적으로는 alert(1)이 실행된다. 1227글자 중에 무려 1186글자를 문자열 만드는 데 썼다 ECMA2015( ES6)부터는 filter 대신 fill을 써도 된다.


또 다른 예제

간단한 예제를 통해 살펴보자.
  • 0.1 + 0.2 = 0.30000000000000004
    number에 number를 더하면 당연히 number이다. 0.3 뒤에 0이 계속 따라붙는 것은 부동소수점의 한계 때문이다.
  • +null = 0
    null 앞에 unary plus를 쓰면 0으로 변환된다.
  • [] + [] = ""
    빈 배열끼리는 바로 더할 수 없기 때문에 .toString() 처리를 하여 "" + "" = ""이 된다.
  • [][[]] = undefined
  • +[][[]] = NaN
  • [] + {} = "[object Object]"
    위와 같이 빈 오브젝트에 .toString() 처리가 되었다.
  • ++[[]][+[]]+[+[]] = "10" (???)

++[[]][+[]]+[+[]]는 의도적으로 사람 헷갈리게 만들려고 작성된 코드이긴 하나, 분석하자면 다음과 같다. 주석으로 관련 링크를 달아놓았으니 정말 그 내부까지 궁금한 사람들은 따라가보길 바란다.
  • 연산자 +++ 보다 우선순위가 높으므로[4] 표면적으로 밖에 위치한 +++ 를 기준으로 하여 코드를 ++[[]][+[]] , +, [+[]]의 세 부분으로 이해한다.
  • +[]에서의 +는 unary operator로, +[]+[].toString()[5]과 동치가 되어 +""가 되고, 빈 문자열 "" 에 대한 unary plus 처리 결과는 0[6]이다.
  • 위의 분석대로 +[]0이므로 원래의 표현은 ++[[]][0], +, [0]의 세 부분으로 볼 수 있다.
  • ++[[]][0]에서 ++ 뒤의 [[]][0]는 빈 배열 1개를 담고 있는 배열의 첫 번째 원소라는 의미이므로 그 안의 배열 []와 동치...일 수도 있으나 그렇다고 미리 줄여서 ++[]로 쓰면 ReferenceError가 뜬다. 이는 ++ 연산자가 레퍼런스를 받아야 하는데, [] 단독으로는 어느 변수에도 바인딩이 되어있지 않으므로 레퍼런스가 존재하지 않는 상태이고 따라서 ++[]는 말하자면 갈 곳 잃은 명령어 상태가 된다.[7] 따라서 ++[[]][0]++[] 로 이해하면 안 된다.
  • 다시 돌아와서, ++의 대상이 [[]][0]라면 이는 배열 안에 있는 배열[8]++, 즉 +1을 시키라는 의미가 되며, [] + 1은 문자열 "1"[9]이 되고, 최종적으로 ++[[]][0]는 배열 안의 배열 []에 1을 더하는 동작을 수행한 뒤에 그 결과값인 숫자 1을 리턴하게 된다.[10]
  • 표현은 1 + [0]으로 단순해졌다. [0]의 ToPrimitive() 값은 문자열 "0"이다. 어느 한쪽이 문자열인 더하기는 toString()을 한 결과물의 합이므로[11] "1" + "0""10"이 된다.

그냥 저런거 필요 없이 "10"을 만들 땐 +!![]+(+[]+[])만 하면 된다. +!![]가 Number 형의 1이고 괄호 안의 +[]+[]는 String 형의 "0"이 되고 문자열과 숫자의 결합은 문자열이 되므로 1+"0"은 결과적으로 String형의 "10"이 된다. [+!![]]+[+[]]도 가능하다. []+[] = ""을 생각해 보아라. 접근방법이 무궁무진(?)하다

3.1. 인터넷 익스플로러용

IE에서는 문자열 추출 파트에서 어긋나기 때문에 오류가 발생한다.부분적으로 실행해보면 'constructor', 'alert(1)'이 되어야 할 것이 'ninstruntir', 'alertr1('와 같이 나타난다. 이는 위의 JSFuck 변환기에서 c, o 등의 문자열을 []['filter']+[] = [].filter.toString()에서 추출하기 때문인데[12], 이 결과가 파이어폭스 등 타 브라우저에서는
"function filter() {
    [native code]
}"

인 데 반해, IE에서는
"
function filter() {
    [native code]
}
"

로 출력되기 때문이다. 잘 보면 앞뒤에 개행 문자가 붙어 있어 글자가 하나씩 밀리는 것을 볼 수 있다. 엣지에서는 이런 문제가 나타나지 않는다.

이론적으로는 []['filter']+[]가 등장하는 부분을 ([]['filter']+[])['trim']() = [].filter.toString().trim()으로 고쳐서 파이어폭스와 인터넷 익스플로러 모두 지원하는 코드를 만들 수는 있으나, JSFuck에서 'm'을 만들려면 Number.toString()을 거쳐야 하고, 이때 위의 문제가 발생하므로 이는 (0)["constructor"].toString() 같은 방식으로 해결해야 한다.

#!syntax javascript
[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[
]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]][([][(![]+[])[+[]]+([![]]+[][[]
])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+
(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[
]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]
+(!![]+[])[+!+[]]])[[+!+[]]+[+!+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+
[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+([![]]
+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+
!+[]]+(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]
+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![
]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[[+!+[]]+[+!+[]]]+(!![]+[])[+!+[]]]((
![]+[])[+!+[]]+(![]+[])[!+[]+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]
+(!![]+[])[+[]]+(![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+
[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[[!+[]+!+
[]]+[+!+[]]]+[+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+
[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[[!
+[]+!+[]]+[+!+[]]])()

1,253자로 더 늘어난 것은 문제가 발생한, 문자열을 추출하는 숫자에 해당하는 부분을 고쳤기 때문이다.

4. 활용

XSS 공격 벡터로 활용될 수 있다. 입력값이 필터링 되는 경우, 특히 알파벳 자체가 필터링 되는 경우에는 특수 문자만을 이용해서 XSS 코드를 삽입해야 하는데, 앞에서도 설명했듯이 자바스크립트는 오직 특수 문자만을 이용해서 코드를 실행시키는 것이 가능하기에 이를 활용하여 공격할 수 있다.

하지만 JSFuck 자체를 이용해 공격하기에는 다소 무리가 있는데 코드가 너무 길어지기 때문이다. 그래서 활용 가능한 특수 문자들[13]을 이용해 직접 코드를 만들어 공격하는 게 일반적이다. JSFuck 자체를 이용한다기보다는 이러한 자바스크립트 성질들을 이용한다고 보면 된다.

워게임이나 CTF에서 간혹 이러한 방식을 이용한 XSS 공격 문제가 출제되고는 한다.

5. 여담

아래의 표에서 볼 수 있듯이 자바스크립트 내부적으로 바로 만들 수 없는 특수문자[14], 특히 한글에 대해서 취약하다.

JSFuck이 6가지 문자로만 표현되는 것에 착안해 코드 한 글자당 \rm log_2 6비트의 공간을 차지한다고 치고 ( UTF-8 기준으로) 원문과 JSFuck 치환 결과의 크기를 비교하면 다음과 같다. 위 홈페이지에서 원하는 문장을 치고 Eval Source(코드로 취급) 체크를 해제하면 실제로 몇 글자로 치환되는지 확인할 수 있다.
원문
(바이트 수)
JSFuck 글자 수
(바이트 수)
원문 대비 용량
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
(446바이트)
104,711글자
(약 33.04KB)
약 75.86배
다람쥐 헌 쳇바퀴에 타고파.
(37바이트)
116,477글자
(약 36.75KB)
1017.19배
나무위키, 여러분이 가꾸어 나가는 지식의 나무.
(64바이트)
201,606글자
(약 63.62KB)
1017.86배
K
(1바이트)
3,561글자
(약 6.43KB)
3561배

최신 자바스크립트에서는 Ecma-262에서 추가된 Array.prototype.at을 사용하면 만들기도 훨씬 쉽고 Array.prototype.fill, Array.prototype.filter을 사용할 때보다 문자량을 훨씬 줄일 수 있다.


[1] ( ) 의 경우에는 함수 실행할때만 쓰는 사람도 있다. [2] 엄밀히 말하자면, 오브젝트형 자료형에 더하기 연산을 시도하면 오브젝트는 원시 자료형으로 형변환을 시도하는데, {Symbol.toPrimitive("default"), .valueOf(), .toString() 순의 우선순위를 가진다. 배열에 Symbol.toPrimitive("default")는 정의되지 않았고, .valueOf() 함수는 일반적으로 객체 자체를 반환하기 때문에 스킵된다. 남은 것은 .toString()밖에 없는데 기본적으로 배열과 객체는 해당 메소드에서 문자열을 반환하므로, .toString()으로 최종적으로 원시 자료형으로 변환된다. # [3] 아니면 주소창에 javascript:라고 치고 그 뒤에 붙여넣고 엔터 쳐도 된다. [4] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence [5] unary plus 연산자는 ToNumber() 처리를 한다고 한다. 따라서 ToNumber()를 확인해야 한다. ToNumber()는 오브젝트(배열은 오브젝트 타입이다)에 대해 ToPrimitive(input, number) 처리를 한 결과물로 ToNumber() 처리를 한다고 하니 ToPrimitive()를 봐야 한다. ToPrimitive()는 preferedType으로 number를 받으면 Symbol.toPrimitive("number"), valueOf(), toString()의 우선순위로 값을 결정한다. 배열에 대해서는 [][Symbol.toPrimitive("number")]는 존재하지 않는 함수고, valueOf() 함수는 빈 배열 객체를 반환하므로 스킵된다. 따라서 원시 자료형을 반환하는 toString()의 결과를 취한다. 배열에 대한 toString()의 결과물은 .join() 명령 호출 결과가 되므로 ""가 된다. [6] 빈 문자열에 대한 ToNumber() 결과0이며, unary plus 연산자는 ToNumber() 처리한 결과를 리턴하므로 최종적으로 숫자 0이 된다. [7] ++ 자신의 파라미터를 PutValue()에 넣는다고 한다. 배열 리터럴 [] 단독으로는 primitive로 취급되므로 레퍼런스가 아니다. 따라서 PutValue() 처리를 하려고 하면 ReferenceError를 낸다. [8] 배열 안에 있는 배열을 [0]이라는 배열 접근자를 이용하여 꺼냈으므로 이는 레퍼런스가 생성된 상태이다. [9] []의 ToPrimitive() 값은 ""이다. 어느 한쪽이 문자열일 때의 더하기이므로 toString() 처리 후 두 문자열을 붙이는 것이 된다. [10] ++ 연산자의 리턴 값은 ToNumber() 처리를 한 숫자이므로 앞서 받아온 문자열 "1"은 숫자 1이 된다. [11] http://www.ecma-international.org/ecma-262/5.1/ [12] 사실 'o', '('는 효율성 문제 때문에 각각 앞에 문자열 'true', 'false'를 붙여서 사용한다. [13] 예를 들어 변수명으로 $나 _를 사용할 수 있다. [14] 심지어 몇몇 대문자도 여기에 들어간다! 예를 들어 소문자 a는 15글자면 만들 수 있지만, 대문자 A를 만들려면 185글자가 필요하며, 심지어 대문자 K는 5,609글자가 필요하다. 소문자 중에서는 z가 1,263자로 가장 많은 글자 수를 필요로 한다.