Open aszx87410 opened 3 years ago
最近公司的同事修了一門資安相關的課,因為我本來就對資安滿有興趣的,所以就會跟同事討論一下,這也導致了我這兩週一直在研究相關的東西,都是一些以前聽過但沒有認真研究過的,例如說 LFI(Local File Inclusion)、REC(Remote code execution)、SSRF(Server-Side Request Forgery)或是各種 PHP 的神奇 filter,也能複習原本就已經相對熟悉的 SQL Injection 跟 XSS。
而 CTF 的題目裡面常常會出現需要繞過各種限制的狀況,而這就是考驗對於特定協定或者是程式語言的理解程度的時機了,要想想看怎麼在既有的限制之下,找出至少一種方法可以成功繞過那些限制。
原本這一週不知道要寫什麼,想寫上面提的那些東西但還沒想好怎麼整理,之前的 I Don't know React 後續系列又還沒整理完,就想說那來跟大家做個跟「繞過限制」有關的趣味小挑戰好了,那就是標題所說的:
在 JavaScript 當中,你可以做到不用英文字母與數字,就成功執行 console.log(1) 嗎?
換句話說,就是程式碼裡面不能出現任何英文字母(a-zA-Z)與數字(0-9),除此之外(各種符號)都可以。執行程式碼之後,會執行 console.log(1),然後在 console 印出 1。
如果你有想到以前聽過什麼有趣的服務或是 library 可以做到,先不要。在這之前可以自己先想一下,看有沒有辦法寫出來,然後再去查其他人的解決方法。
若是能從零到有全都自己寫出來,就代表你對 JS 這個程式語言以及各種自動轉型的熟悉程度應該是滿高的。
底下我就提供一下我自己針對這一題的一些想法以及解題過程,有雷,還沒解完不要往下捲動。
==防雷== ==防雷== ==防雷== ==防雷== ==防雷== ==防雷== ==防雷== ==防雷== ==防雷== ==防雷== ==防雷== ==防雷== ==防雷==
要能成功執行題目所要求的 console.log(1),必須要完成幾件事情,像是:
console.log(1)
只要這三點都解開了,應該就能達成題目所要求的東西。
讓我們先來想想第一點:「要怎麼執行程式碼?」
直接 console.log 是不可能的,因為就算你用字串拼出 console,你也沒辦法像 PHP 那樣拿字串來執行函式。
console.log
那 eval 呢?eval 裡面可以放字串,就可以執行任意程式碼了!可是問題是,我們也沒辦法用 eval,因為不能打英文字。
還有什麼方法呢?還可以用 function constructor:new Function("console.log(1)") 來執行,但問題是我們也不能用 new 這個關鍵字,所以乍看之下也不行。不過其實不需要 new 也可以,只要 Function("console.log(1)") 就可以建立一個能夠執行特定程式碼的函式。
new Function("console.log(1)")
Function("console.log(1)")
所以接下來的問題就變成:那我們該如何拿到 function constructor?只要能夠拿到就有機會了。
在 JS 裡面可以用 .constructor 拿到某個東西的 constructor,例如說 "".constructor 就會得到:ƒ String() { [native code] },而今天如果你有一個 function,就可以拿到 function constructor 了,像是這樣:(()=>{}).constructor,然後因為我們可以預期這一題會是用字串拼出各種東西,所以沒辦法直接 .constructor,應該改成:(()=>{})['constructor']。
.constructor
"".constructor
ƒ String() { [native code] }
(()=>{}).constructor
(()=>{})['constructor']
那如果不支援 ES6 了?沒辦法支援箭頭函式怎麼辦?有什麼方法可以拿到一個函式嗎?
有,而且很容易,就是各種內建函式,例如說 []['fill']['constructor'],其實就是 [].fill.constructor,或者是 ""['slice']['constructor'],也可以拿到 function constructor,所以這不是一件難事,就算沒有箭頭函式也可以拿到。
[]['fill']['constructor']
[].fill.constructor
""['slice']['constructor']
一開始我們期望的程式碼是這樣:Function('console.log(1)')(),用上面改寫的話,就會把前面的 Function 替換成 (()=>{})['constructor'],變成:(()=>{})['constructor']('console.log(1)')()
Function('console.log(1)')()
Function
(()=>{})['constructor']('console.log(1)')()
只要能湊出這一段,問題就解決了。至此,我們已經解決了第一個問題:執行函式。
接下來因為數字比較簡單,所以我們先來想一下怎麼湊出數字好了。
這邊的關鍵就在於 JS 的 coercion,如果你有看過一些 JS 轉型的文章,或許會記得 {}+[] 可以得出 0 這個數字。
{}+[]
就算不記得好了,利用 ! 這個運算子,我們可以得出 false,例如說 ![] 或是 !{} 都可以得出 false。然後兩個 false 相加就可以得到 0:![]+![],以此類推,既然 ![] 是 false,那前面再加一個 not,!![] 就是 true,所以![] + !![] 就等於 false + true,也就是 0 + 1,結果就會是 1。
![]
!{}
![]+![]
!![]
![] + !![]
或其實也有更短的方法,用 +[] 也可以利用自動轉型得到 0 這個結果,那 +!![] 就是 1。
+[]
+!![]
有了 1 之後,就可以湊出所有數字了,因為你只要一直暴力不斷相加就好了,有多少就加多少次。或如果你不想這樣做,也可以利用位元運算 << >> 或者是乘號,比如說要湊出 8,就是 1 << 3,或者是 2 << 2,那要湊出 2 就是 (+!![])+(+!![]),所以 (+!![])+(+!![]) << (+!![])+(+!![]) 就會是 8,只要四個 1 就行了,不需要自己加 8 次。
1 << 3
2 << 2
(+!![])+(+!![])
(+!![])+(+!![]) << (+!![])+(+!![])
不過我們可以先不考慮長度,只要考慮能不能湊出來就行了,只要湊出 1 我們就已經獲勝了。
最後呢,就是要想辦法湊出字串了,或者換句話說,要能湊出 (()=>{})['constructor']('console.log(1)')() 裡面的各個字元。
可是我們要怎麼樣才能湊出字元呢?
關鍵跟數字一樣,就是 coercion!
上面有講過 ![] 可以拿到 false,那你後面再加一個字串:![] + '',不就可以拿到 "false" 了嗎?那這樣我們就可以拿到 a, e, f, l, s 這五個字元。舉例來說,(![] + '')[1] 就是 a,為了方便紀錄,我們來寫一點小程式吧!
![] + ''
"false"
(![] + '')[1]
const mapping = { a: "(![] + '')[1]", e: "(![] + '')[4]", f: "(![] + '')[0]", l: "(![] + '')[2]", s: "(![] + '')[3]", }
那既然有了 false,拿到 true 也不是一件難事,!![] + '' 就可以拿到 true,我們的程式碼就可以改成:
!![] + ''
true
const mapping = { a: "(![] + '')[1]", e: "(![] + '')[4]", f: "(![] + '')[0]", l: "(![] + '')[2]", r: "(!![] + '')[1]", s: "(![] + '')[3]", t: "(!![] + '')[0]", u: "(!![] + '')[2]", }
再來呢?再來一樣利用轉型,用 ''+{} 可以得到 "[object Object]"(或是你要用神奇的 []+{} 也行),我們的表就可以更新成這樣:
''+{}
"[object Object]"
[]+{}
const mapping = { a: "(![] + '')[1]", b: "(''+{})[2]", c: "(''+{})[5]", e: "(![] + '')[4]", f: "(![] + '')[0]", j: "(''+{})[3]", l: "(![] + '')[2]", o: "(''+{})[1]", r: "(!![] + '')[1]", s: "(![] + '')[3]", t: "(!![] + '')[0]", u: "(!![] + '')[2]", }
再來,從陣列或是物件拿一個不存在的屬性會回傳什麼?undefined,再把 undefined 加上字串,就可以拿到字串的 undefined,像是這樣:[][{}]+'',就可以拿到 undefined。
[][{}]+''
undefined
拿到之後,我們的轉換表就變得更加完整了:
const mapping = { a: "(![] + '')[1]", b: "(''+{})[2]", c: "(''+{})[5]", d: "([][{}]+'')[2]", e: "(![] + '')[4]", f: "(![] + '')[0]", i: "([][{}]+'')[5]", j: "(''+{})[3]", l: "(![] + '')[2]", n: "([][{}]+'')[1]", o: "(''+{})[1]", r: "(!![] + '')[1]", s: "(![] + '')[3]", t: "(!![] + '')[0]", u: "(!![] + '')[2]", }
看了一下轉換表,再看一下我們的目標字串:(()=>{})['constructor']('console["log"](1)')(),稍微比對一下,發現要湊出 constructor 是沒有問題的,要湊出 console 也是沒問題的,可是就唯獨缺了 log 的 g,我們目前的轉換表裡面沒有這個字元。
(()=>{})['constructor']('console["log"](1)')()
constructor
console
g
所以一定還要再從某個地方把 g 拿出來,才能湊出我們想要的字串。或者也可以換個方法,用別的方式拿到字元。
我當初想到兩個方法,第一個方法是利用進位轉換,把數字用 toString 轉成字串的時候,其實可以帶一個參數 radix,代表這個數字要轉換成多少進制,像是 (10).toString(16) 就會得到 a,因為 10 進制的 10 就是 16 進制的 a。
(10).toString(16)
英文字母一共 26 個,數字有 10 個,所以只要用 (10).toString(36) 就能得到 a,用 (16).toString(36) 就可以得到 g 了,我們可以用這個方法拿到所有的英文字母。可是問題來了,那就是 toString 本身也有 g,但我們現在沒有,所以這方法行不通。
(10).toString(36)
(16).toString(36)
toString
另外一個當初想到的方法是用 base64,JS 有內建兩個函式:btoa 跟 atob,btoa 是把一個字串 encode 成 base64,例如說 btoa('abc') 會得到 YWJj,然後再用 atob('YWJj') 做 decode 就會得到 abc。
btoa
atob
btoa('abc')
atob('YWJj')
我們只要想辦法讓 base64 encode 後的結果有 g 就沒問題了,這邊可以寫程式去跑也可以自己慢慢試,很幸運地,btoa(2) 就能拿到 Mg== 這個字串。所以 btoa(2)[1] 就會是 g 了。
btoa(2)
Mg==
btoa(2)[1]
不過下一個問題來了,我們要怎麼執行 btoa?一樣只能透過上面的 function constructor:(()=>{})['constructor']('return btoa(2)[1]')(),而這次很幸運地,上面的每一個字元我們都湊得出來!
(()=>{})['constructor']('return btoa(2)[1]')()
我們可以結合上面的 mapping,寫一個簡單的小程式來幫我們做轉換,目標是把一個字串轉成沒有字元的形式:
const mapping = { a: "(![] + '')[1]", b: "(''+{})[2]", c: "(''+{})[5]", d: "([][{}]+'')[2]", e: "(![] + '')[4]", f: "(![] + '')[0]", i: "([][{}]+'')[5]", j: "(''+{})[3]", l: "(![] + '')[2]", n: "([][{}]+'')[1]", o: "(''+{})[1]", r: "(!![] + '')[1]", s: "(![] + '')[3]", t: "(!![] + '')[0]", u: "(!![] + '')[2]", } const one = '(+!![])' const zero = '(+[])' function transformString(input) { return input.split('').map(char => { // 先假設數字只會有個位數,比較好做轉換 if (/[0-9]/.test(char)) { if (char === '0') return zero return Array(+char).fill().map(_ => one).join('+') } if (/[a-zA-Z]/.test(char)) { return mapping[char] } return `"${char}"` }) // 加上 () 保證執行順序 .map(char => `(${char})`) .join('+') } const input = 'constructor' console.log(transformString(input))
輸出是:
((''+{})[5])+((''+{})[1])+(([][{}]+'')[1])+((![] + '')[3])+((!![] + '')[0])+((!![] + '')[1])+((!![] + '')[2])+((''+{})[5])+((!![] + '')[0])+((''+{})[1])+((!![] + '')[1])
可以再寫一個函式只轉換數字,把數字去掉:
function transformNumber(input) { return input.split('').map(char => { // 先假設數字只會有個位數,比較好做轉換 if (/[0-9]/.test(char)) { if (char === '0') return zero let newChar = Array(+char).fill().map(_ => one).join('+') return`(${newChar})` } return char }) .join('') } const input = 'constructor' console.log(transformNumber(transformString(input)))
得到的結果是:
((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])
把這結果丟去 console 執行,發現得到的值就是 constructor 沒錯。所以綜合以上程式,回到我們剛剛那一段:(()=>{})['constructor']('return btoa(2)[1]')(),要得到轉換完的結果,就是:
const con = transformNumber(transformString('constructor')) const fn = transformNumber(transformString('return btoa(2)[1]')) const result = `(()=>{})[${con}](${fn})()` console.log(result)
結果超級長我就先不貼了,但確實能得到一個字串 g。
在繼續往下之前,先讓我們把程式改一下,新增一個能夠直接轉換程式碼的函式:
function transform(code) { const con = transformNumber(transformString('constructor')) const fn = transformNumber(transformString(code)) const result = `(()=>{})[${con}](${fn})()` return result; } console.log(transform('return btoa(2)[1]'))
好,做到這邊其實我們已經接近終點了,只差有一件事情沒有解決,那就是 btoa 其實是 WebAPI,瀏覽器才有,Node.js 並沒有這函式,所以想要解得更漂亮,就必須找到其他方式來產生 g 這個字元。
可以回憶一下一開始所提的,用 function.constructor 可以拿到 function constructor,所以以此類推,用 ''['constructor'] 可以拿到 string constructor,只要再加上一個字串,就可以拿到 string constructor 的內容了!
function.constructor
''['constructor']
像是這樣:''['constructor'] + '',得到的結果是:"function String() { [native code] }",一瞬間多了堆字串可以用,而我們朝思暮想的 g 就是:(''['constructor'] + '')[14]。
''['constructor'] + ''
"function String() { [native code] }"
(''['constructor'] + '')[14]
由於我們的轉換器目前只能支援一個位數的數字(因為做起來簡單),我們改成:(''['constructor'] + '')[7+7],可以寫成這樣:
(''['constructor'] + '')[7+7]
mapping['g'] = transform(`return (''['constructor'] + '')[7+7]`)
經歷過千辛萬苦之後,我們終於湊出了最麻煩的 g 這個字元,結合我們剛剛寫好的轉換器,就可以順利產生 console.log(1) 去除掉字母與數字過後的版本:
const mapping = { a: "(![] + '')[1]", b: "(''+{})[2]", c: "(''+{})[5]", d: "([][{}]+'')[2]", e: "(![] + '')[4]", f: "(![] + '')[0]", i: "([][{}]+'')[5]", j: "(''+{})[3]", l: "(![] + '')[2]", n: "([][{}]+'')[1]", o: "(''+{})[1]", r: "(!![] + '')[1]", s: "(![] + '')[3]", t: "(!![] + '')[0]", u: "(!![] + '')[2]", } const one = '(+!![])' const zero = '(+[])' function transformString(input) { return input.split('').map(char => { // 先假設數字只會有個位數,比較好做轉換 if (/[0-9]/.test(char)) { if (char === '0') return zero return Array(+char).fill().map(_ => one).join('+') } if (/[a-zA-Z]/.test(char)) { return mapping[char] } return `"${char}"` }) // 加上 () 保證執行順序 .map(char => `(${char})`) .join('+') } function transformNumber(input) { return input.split('').map(char => { // 先假設數字只會有個位數,比較好做轉換 if (/[0-9]/.test(char)) { if (char === '0') return zero let newChar = Array(+char).fill().map(_ => one).join('+') return`(${newChar})` } return char }) .join('') } function transform(code) { const con = transformNumber(transformString('constructor')) const fn = transformNumber(transformString(code)) const result = `(()=>{})[${con}](${fn})()` return result; } mapping['g'] = transform(`return (''['constructor'] + '')[7+7]`) console.log(transform('console.log(1)'))
最後產生出來的程式碼:
(()=>{})[((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])](((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+((![] + '')[((+!![])+(+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![])+(+!![]))])+(".")+((![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![]))])+((()=>{})[((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])](((!![] + '')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![])+(+!![]))])+((!![] + '')[((+!![]))])+(([][{}]+'')[((+!![]))])+(" ")+("(")+("'")+("'")+("[")+("'")+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])+("'")+("]")+(" ")+("+")+(" ")+("'")+("'")+(")")+("[")+((+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![]))+("+")+((+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![]))+("]"))())+("(")+((+!![]))+((+!![])+(+!![]))+((+!![])+(+!![])+(+!![]))+(")"))()
至此,我們用了 1800 個字元,成功製造出只有:[, ], (, ), {, }, ", ', +, !, =, > 這 12 個字元的程式,並且能夠順利執行 console.log(1)。
[
]
(
)
{
}
"
'
+
!
=
>
而因為我們已經可以順利拿到 String 這幾個字了,所以就可以用之前提過的進位轉換的方法,得到任意小寫字元,像是這樣:
mapping['S'] = transform(`return (''['constructor'] + '')[9]`) mapping['g'] = transform(`return (''['constructor'] + '')[7+7]`) console.log(transform('return (35).toString(36)')) // z
那要怎麼拿到任意大寫字元,或甚至任意字元呢?我也有想到幾種方式。
如果想拿到任意字元,可以透過 String.fromCharCode,或是寫成另一種形式:""['constructor']['fromCharCode'],就可以拿到任意字元。可是在這之前要先想辦法拿到大寫的 C,這個就要再想一下怎麼做了。
String.fromCharCode
""['constructor']['fromCharCode']
除了這條路,還有另外一條,那就是靠編碼,例如說 '\u0043' 其實就是大寫的 C 了,所以我原本以為可以透過這種方法來湊,但我試了一下是不行的,像是 console.log("\u0043") 會印出 C 沒錯,但是 console.log(("\u00" + "43")) 就會直接噴一個錯誤給你,看來編碼沒有辦法這樣拼起來(仔細想想發現滿合理的)。
'\u0043'
console.log("\u0043")
console.log(("\u00" + "43"))
其實我以前有寫過一篇:讓 JavaSript 難以閱讀:jsfuck 與 aaencode,在講的就是同一件事,不過以前我只有稍微整理一下,這次則是自己親自下去試過,感覺更不一樣。
最後寫出來的那個轉換的函式其實並不完整,沒有辦法執行任意程式碼,沒有繼續做完是因為 jsfuck 這個 library 已經寫得很清楚了,在 README 裡面有詳細描述它的轉換過程,而且最後只用了 6 個字元而已,真的很佩服。
在它的程式碼當中也可以看出他的轉換是怎麼做的,大寫 C 的部分是用一個在 String 身上叫做 italics 的函式,可以產生出 <i></i>,產生出以後再呼叫 escape 去做跳脫,就會得到 %3Ci%3E%3C/i%3E,就有大寫 C 了。
italics
<i></i>
%3Ci%3E%3C/i%3E
有些人可能會想說平常程式碼寫得好好的,幹嘛這樣搞自己,但這樣做的重點其實不在於最後的結果,而是在訓練幾個東西,像是:
總之呢,以上是我針對這一題的一些解題心路歷程,有什麼有趣的解法也歡迎留言讓我知道(例如說其他種拿到大寫字母 C 的做法),感謝!
前言
最近公司的同事修了一門資安相關的課,因為我本來就對資安滿有興趣的,所以就會跟同事討論一下,這也導致了我這兩週一直在研究相關的東西,都是一些以前聽過但沒有認真研究過的,例如說 LFI(Local File Inclusion)、REC(Remote code execution)、SSRF(Server-Side Request Forgery)或是各種 PHP 的神奇 filter,也能複習原本就已經相對熟悉的 SQL Injection 跟 XSS。
而 CTF 的題目裡面常常會出現需要繞過各種限制的狀況,而這就是考驗對於特定協定或者是程式語言的理解程度的時機了,要想想看怎麼在既有的限制之下,找出至少一種方法可以成功繞過那些限制。
原本這一週不知道要寫什麼,想寫上面提的那些東西但還沒想好怎麼整理,之前的 I Don't know React 後續系列又還沒整理完,就想說那來跟大家做個跟「繞過限制」有關的趣味小挑戰好了,那就是標題所說的:
換句話說,就是程式碼裡面不能出現任何英文字母(a-zA-Z)與數字(0-9),除此之外(各種符號)都可以。執行程式碼之後,會執行 console.log(1),然後在 console 印出 1。
如果你有想到以前聽過什麼有趣的服務或是 library 可以做到,先不要。在這之前可以自己先想一下,看有沒有辦法寫出來,然後再去查其他人的解決方法。
若是能從零到有全都自己寫出來,就代表你對 JS 這個程式語言以及各種自動轉型的熟悉程度應該是滿高的。
底下我就提供一下我自己針對這一題的一些想法以及解題過程,有雷,還沒解完不要往下捲動。
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
分析解題的幾個關鍵
要能成功執行題目所要求的
console.log(1)
,必須要完成幾件事情,像是:只要這三點都解開了,應該就能達成題目所要求的東西。
讓我們先來想想第一點:「要怎麼執行程式碼?」
直接
console.log
是不可能的,因為就算你用字串拼出 console,你也沒辦法像 PHP 那樣拿字串來執行函式。那 eval 呢?eval 裡面可以放字串,就可以執行任意程式碼了!可是問題是,我們也沒辦法用 eval,因為不能打英文字。
還有什麼方法呢?還可以用 function constructor:
new Function("console.log(1)")
來執行,但問題是我們也不能用 new 這個關鍵字,所以乍看之下也不行。不過其實不需要 new 也可以,只要Function("console.log(1)")
就可以建立一個能夠執行特定程式碼的函式。所以接下來的問題就變成:那我們該如何拿到 function constructor?只要能夠拿到就有機會了。
在 JS 裡面可以用
.constructor
拿到某個東西的 constructor,例如說"".constructor
就會得到:ƒ String() { [native code] }
,而今天如果你有一個 function,就可以拿到 function constructor 了,像是這樣:(()=>{}).constructor
,然後因為我們可以預期這一題會是用字串拼出各種東西,所以沒辦法直接.constructor
,應該改成:(()=>{})['constructor']
。那如果不支援 ES6 了?沒辦法支援箭頭函式怎麼辦?有什麼方法可以拿到一個函式嗎?
有,而且很容易,就是各種內建函式,例如說
[]['fill']['constructor']
,其實就是[].fill.constructor
,或者是""['slice']['constructor']
,也可以拿到 function constructor,所以這不是一件難事,就算沒有箭頭函式也可以拿到。一開始我們期望的程式碼是這樣:
Function('console.log(1)')()
,用上面改寫的話,就會把前面的Function
替換成(()=>{})['constructor']
,變成:(()=>{})['constructor']('console.log(1)')()
只要能湊出這一段,問題就解決了。至此,我們已經解決了第一個問題:執行函式。
如何湊出數字
接下來因為數字比較簡單,所以我們先來想一下怎麼湊出數字好了。
這邊的關鍵就在於 JS 的 coercion,如果你有看過一些 JS 轉型的文章,或許會記得
{}+[]
可以得出 0 這個數字。就算不記得好了,利用 ! 這個運算子,我們可以得出 false,例如說
![]
或是!{}
都可以得出 false。然後兩個 false 相加就可以得到 0:![]+![]
,以此類推,既然![]
是 false,那前面再加一個 not,!![]
就是 true,所以![] + !![]
就等於 false + true,也就是 0 + 1,結果就會是 1。或其實也有更短的方法,用
+[]
也可以利用自動轉型得到 0 這個結果,那+!![]
就是 1。有了 1 之後,就可以湊出所有數字了,因為你只要一直暴力不斷相加就好了,有多少就加多少次。或如果你不想這樣做,也可以利用位元運算 << >> 或者是乘號,比如說要湊出 8,就是
1 << 3
,或者是2 << 2
,那要湊出 2 就是(+!![])+(+!![])
,所以(+!![])+(+!![]) << (+!![])+(+!![])
就會是 8,只要四個 1 就行了,不需要自己加 8 次。不過我們可以先不考慮長度,只要考慮能不能湊出來就行了,只要湊出 1 我們就已經獲勝了。
如何湊出字串?
最後呢,就是要想辦法湊出字串了,或者換句話說,要能湊出
(()=>{})['constructor']('console.log(1)')()
裡面的各個字元。可是我們要怎麼樣才能湊出字元呢?
關鍵跟數字一樣,就是 coercion!
上面有講過
![]
可以拿到 false,那你後面再加一個字串:![] + ''
,不就可以拿到"false"
了嗎?那這樣我們就可以拿到 a, e, f, l, s 這五個字元。舉例來說,(![] + '')[1]
就是 a,為了方便紀錄,我們來寫一點小程式吧!那既然有了 false,拿到 true 也不是一件難事,
!![] + ''
就可以拿到true
,我們的程式碼就可以改成:再來呢?再來一樣利用轉型,用
''+{}
可以得到"[object Object]"
(或是你要用神奇的[]+{}
也行),我們的表就可以更新成這樣:再來,從陣列或是物件拿一個不存在的屬性會回傳什麼?undefined,再把 undefined 加上字串,就可以拿到字串的 undefined,像是這樣:
[][{}]+''
,就可以拿到undefined
。拿到之後,我們的轉換表就變得更加完整了:
看了一下轉換表,再看一下我們的目標字串:
(()=>{})['constructor']('console["log"](1)')()
,稍微比對一下,發現要湊出constructor
是沒有問題的,要湊出console
也是沒問題的,可是就唯獨缺了 log 的g
,我們目前的轉換表裡面沒有這個字元。所以一定還要再從某個地方把 g 拿出來,才能湊出我們想要的字串。或者也可以換個方法,用別的方式拿到字元。
我當初想到兩個方法,第一個方法是利用進位轉換,把數字用 toString 轉成字串的時候,其實可以帶一個參數 radix,代表這個數字要轉換成多少進制,像是
(10).toString(16)
就會得到 a,因為 10 進制的 10 就是 16 進制的 a。英文字母一共 26 個,數字有 10 個,所以只要用
(10).toString(36)
就能得到 a,用(16).toString(36)
就可以得到 g 了,我們可以用這個方法拿到所有的英文字母。可是問題來了,那就是toString
本身也有 g,但我們現在沒有,所以這方法行不通。另外一個當初想到的方法是用 base64,JS 有內建兩個函式:
btoa
跟atob
,btoa 是把一個字串 encode 成 base64,例如說btoa('abc')
會得到 YWJj,然後再用atob('YWJj')
做 decode 就會得到 abc。我們只要想辦法讓 base64 encode 後的結果有 g 就沒問題了,這邊可以寫程式去跑也可以自己慢慢試,很幸運地,
btoa(2)
就能拿到Mg==
這個字串。所以btoa(2)[1]
就會是g
了。不過下一個問題來了,我們要怎麼執行 btoa?一樣只能透過上面的 function constructor:
(()=>{})['constructor']('return btoa(2)[1]')()
,而這次很幸運地,上面的每一個字元我們都湊得出來!我們可以結合上面的 mapping,寫一個簡單的小程式來幫我們做轉換,目標是把一個字串轉成沒有字元的形式:
輸出是:
可以再寫一個函式只轉換數字,把數字去掉:
得到的結果是:
把這結果丟去 console 執行,發現得到的值就是
constructor
沒錯。所以綜合以上程式,回到我們剛剛那一段:(()=>{})['constructor']('return btoa(2)[1]')()
,要得到轉換完的結果,就是:結果超級長我就先不貼了,但確實能得到一個字串 g。
在繼續往下之前,先讓我們把程式改一下,新增一個能夠直接轉換程式碼的函式:
好,做到這邊其實我們已經接近終點了,只差有一件事情沒有解決,那就是 btoa 其實是 WebAPI,瀏覽器才有,Node.js 並沒有這函式,所以想要解得更漂亮,就必須找到其他方式來產生 g 這個字元。
可以回憶一下一開始所提的,用
function.constructor
可以拿到 function constructor,所以以此類推,用''['constructor']
可以拿到 string constructor,只要再加上一個字串,就可以拿到 string constructor 的內容了!像是這樣:
''['constructor'] + ''
,得到的結果是:"function String() { [native code] }"
,一瞬間多了堆字串可以用,而我們朝思暮想的 g 就是:(''['constructor'] + '')[14]
。由於我們的轉換器目前只能支援一個位數的數字(因為做起來簡單),我們改成:
(''['constructor'] + '')[7+7]
,可以寫成這樣:結合所有努力
經歷過千辛萬苦之後,我們終於湊出了最麻煩的 g 這個字元,結合我們剛剛寫好的轉換器,就可以順利產生
console.log(1)
去除掉字母與數字過後的版本:最後產生出來的程式碼:
至此,我們用了 1800 個字元,成功製造出只有:
[
,]
,(
,)
,{
,}
,"
,'
,+
,!
,=
,>
這 12 個字元的程式,並且能夠順利執行console.log(1)
。而因為我們已經可以順利拿到 String 這幾個字了,所以就可以用之前提過的進位轉換的方法,得到任意小寫字元,像是這樣:
那要怎麼拿到任意大寫字元,或甚至任意字元呢?我也有想到幾種方式。
如果想拿到任意字元,可以透過
String.fromCharCode
,或是寫成另一種形式:""['constructor']['fromCharCode']
,就可以拿到任意字元。可是在這之前要先想辦法拿到大寫的 C,這個就要再想一下怎麼做了。除了這條路,還有另外一條,那就是靠編碼,例如說
'\u0043'
其實就是大寫的 C 了,所以我原本以為可以透過這種方法來湊,但我試了一下是不行的,像是console.log("\u0043")
會印出 C 沒錯,但是console.log(("\u00" + "43"))
就會直接噴一個錯誤給你,看來編碼沒有辦法這樣拼起來(仔細想想發現滿合理的)。總結
其實我以前有寫過一篇:讓 JavaSript 難以閱讀:jsfuck 與 aaencode,在講的就是同一件事,不過以前我只有稍微整理一下,這次則是自己親自下去試過,感覺更不一樣。
最後寫出來的那個轉換的函式其實並不完整,沒有辦法執行任意程式碼,沒有繼續做完是因為 jsfuck 這個 library 已經寫得很清楚了,在 README 裡面有詳細描述它的轉換過程,而且最後只用了 6 個字元而已,真的很佩服。
在它的程式碼當中也可以看出他的轉換是怎麼做的,大寫 C 的部分是用一個在 String 身上叫做
italics
的函式,可以產生出<i></i>
,產生出以後再呼叫 escape 去做跳脫,就會得到%3Ci%3E%3C/i%3E
,就有大寫 C 了。有些人可能會想說平常程式碼寫得好好的,幹嘛這樣搞自己,但這樣做的重點其實不在於最後的結果,而是在訓練幾個東西,像是:
總之呢,以上是我針對這一題的一些解題心路歷程,有什麼有趣的解法也歡迎留言讓我知道(例如說其他種拿到大寫字母 C 的做法),感謝!