從一道毫無人性的刁鉆面試題說起
問題:在 JavaScript 中,你可以不用英文字母與數(shù)字,就執(zhí)行 console.log(1) 嗎?
換句話說,就在于代碼中不能出現(xiàn)任何英文字母(a-zA-Z)與數(shù)字(0-9),除此之外(各種符號)都可以。執(zhí)行式碼之后,會(huì)執(zhí)行 console.log(1),然后在控制臺中輸出 1。
如果你想到可以用什么庫或服務(wù)之類的東西做到,別急著說出答案。先自己想一下,看看有沒有辦法自己寫出來。如果能從零開始自己寫出來,就代表你對 js 這個(gè)語言以及各種自動(dòng)類型轉(zhuǎn)換應(yīng)該是很熟悉的。
分析幾個(gè)關(guān)鍵點(diǎn)
要能成功執(zhí)行題目所要求的的 console.log(1),必須要完成幾個(gè)關(guān)鍵點(diǎn):
- 找出執(zhí)行代碼的方法
- 如何不用字母與數(shù)字得出數(shù)字的方法
- 如何不用字母與數(shù)字得到字母的方法
只要這三點(diǎn)都解決了,就能達(dá)成題目的要求。
先解決第一點(diǎn):找出執(zhí)行代碼的方法
找出執(zhí)行代碼的方法
直接 console.log 是不可能的,因?yàn)榫退隳阌米址闯?console,你也沒辦法像 PHP 那樣拿字符串來執(zhí)行函數(shù)。
那 eval 呢?evali 里面可以放字符串,可以是就可以。問題是我們也沒法使用 eval,因?yàn)椴荒苡糜⑽淖帜浮?/p>
還有什么方法呢?還可以用 function constructor:new Function("console.log(1)") 來執(zhí)行,但問題是我們也不能用 new 這個(gè)關(guān)鍵字,所以乍一看也不行,不過不需要 new 也可以,只用 Function("console.log(1)") 就可以創(chuàng)建一個(gè)能夠執(zhí)行特定代碼的函數(shù)。
所以接下來的問題就變成了怎樣才能拿到 function constructor,只要能拿到就有機(jī)會(huì)。
在 JS 中可以用 .Constructor 拿到某個(gè)對象的構(gòu)造函數(shù),例如 "".constructor 就會(huì)得到:ƒ String() { [native code] },如果你有一個(gè)函數(shù),就能拿到 function constructor,像這樣:(()=>{}).constructor,在這個(gè)問題中我們不能直接用 .constructor,應(yīng)該用:(()=>{})['constructor']。
如果不支持 ES6 ,不能用箭頭函數(shù)怎么辦,還有辦法得到一個(gè)函數(shù)嗎?
有,而且很容易,就是各種內(nèi)置函數(shù),例如說 []['fill']['constructor'],其實(shí)就是 [].fill.constructor,或者是 ""['slice']['constructor'],也可以拿到 function constructor,所以這不是個(gè)問題,就算沒有箭頭函數(shù)也沒關(guān)系。
一開始我們期望的代碼是這樣:Function('console.log(1)')(),用前面的方法改寫的話,應(yīng)該把前面的 Function 替換成 (()=>{})['constructor'],變成 (()=>{})['constructor']('console.log(1)')()只要想辦法拼湊出這段代碼問題就解決了?,F(xiàn)在我們解決了第一個(gè)問題:找到執(zhí)行函數(shù)的方法。
如何得到數(shù)字
接下來的數(shù)字就比較簡單了。
這里的關(guān)鍵在與 js 的強(qiáng)制多態(tài),如果你有看過 js 類型轉(zhuǎn)換的文章,或許會(huì)記得 {} + [] 可以得出 0 這個(gè)數(shù)字。
假設(shè)你不知道這個(gè),我來解釋一下:利用 ! 這個(gè)運(yùn)算符,可以得到 false,例如 1[]或者 !{} 都可以得出 false。然后兩個(gè) false 相加就可得到 0:![] + ![] ,以此類推,既然 ![] 是 false,那前面再加一個(gè) !,!![] 就是 true,所以 ![] + !![] 就等于 false + true,也就是 0 + 1,結(jié)果就是 1。
或者用更簡短的方法,用來 +[] 也可以利用自動(dòng)類型轉(zhuǎn)換得到 0 這個(gè)結(jié)果,那么 +!![] 就是 1。
有了 1 之后,就可以得到所有數(shù)字了,只要一直不斷暴力相加就行了,如果不想這樣做,也可以利用位運(yùn)算 << >> 或者是乘號,比如說要湊出 8,就是 1 << 3,或者是 2 << 2,要湊出 2 就是(+!![])+(+!![]),所以 (+!![])+(+!![]) << (+!![])+(+!![]) 就是 8,只需要四個(gè) 1 就行了,不需要自己加 8 次。
不過現(xiàn)在可以先不考慮長度,只需要考慮能不能湊出來就行了,只要能得出 1 就足夠了。
如何得到字符串
最后就是要想辦法湊出字符串了,或者說要得到 (()=>{})['constructor']('console.log(1)')() 中的每一個(gè)字符。
怎樣才能得到字符呢?答案是和數(shù)字一樣,即強(qiáng)制多態(tài)。
上面說過 ![] 可以得到 false,那在后面加一個(gè)空字符串:![] + '',不就可以得到 "false" 了嗎?這樣就可以拿到 a, e, f, l, s 這五個(gè)字符。例如 (![] + '')[1]就是 a,為了方便紀(jì)錄,我們來寫一小段代碼:
- const mapping = {
- a: "(![] + '')[1]",
- e: "(![] + '')[4]",
- f: "(![] + '')[0]",
- l: "(![] + '')[2]",
- s: "(![] + '')[3]",
- }
那既然有了false,那么拿到 true 也不是什么難事了,!![] + '' 可以得到 true,現(xiàn)在把代碼改成:
- const mapping = {
- a: "(![] + '')[1]",
- e: "(![] + '')[4]",
- f: "(![] + '')[0]",
- l: "(![] + '')[2]",
- r: "(!![] + '')[1]",
- s: "(![] + '')[3]",
- t: "(!![] + '')[0]",
- u: "(!![] + '')[2]",
- }
然后再用同樣的方法,用 ''+{} 可以得到 "[object Object]"(或是你要用神奇的 []+{} 也行),現(xiàn)在代碼可以更新成這樣:
- 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]",
- }
從數(shù)組或是對象取一個(gè)不存在的屬性會(huì)返回 undefined,再把 undefined 加上字串,就可以拿到字串的 undefined,就像這樣:[][{}]+'',可以得到 undefined。
拿到之后,我們的轉(zhuǎn)換表就變得更加完整了:
- 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]",
- }
看一下轉(zhuǎn)換表,再看看我們的目標(biāo)字符串:(()=>{})['constructor']('console["log"](1)')(),稍微比對一下,就會(huì)發(fā)現(xiàn)要湊出 constructor 是沒有問題的,要湊出 console 也是沒問題的,可是就唯獨(dú)缺了 log 的 g,目前我們的轉(zhuǎn)換表里面沒有這個(gè)字符。
所以還需要從某個(gè)地方把 g 拿出來,才能拼湊出我們想要的字符串。或者也可以換個(gè)方法,用其他方式拿到字符。
我一開始想到兩個(gè)方法,第一個(gè)是利用進(jìn)制轉(zhuǎn)換,把數(shù)字用 toString 轉(zhuǎn)成字符串時(shí)可以帶一個(gè)參數(shù) radix,代表這個(gè)數(shù)字要轉(zhuǎn)換成多少進(jìn)制,像是 (10).toString(16) 就會(huì)得到 a,因?yàn)?10 進(jìn)制的 10 就是 16 進(jìn)制的 a。
英文字母一共 26 個(gè),數(shù)字有 10 個(gè),所以只要用 (10).toString(36) 就能得到 a,用 (16).toString(36) 就可以得到 g 了,可以用這個(gè)方法得到所有的英文字母??墒菃栴}來了, toString 本身也有 g,但現(xiàn)在我們沒有,所以這方法行不通。
另一個(gè)方法是用 base64,JS 有兩個(gè)內(nèi)置函數(shù):btoa 跟 atob,btoa 是把一個(gè)字符串編碼為 base64,例如 btoa('abc') 會(huì)得到 YWJj,然后再用 atob('YWJj') 解碼就會(huì)得到 abc。
只要想辦法讓 base64 編碼后的結(jié)果有 g 就行了,可以寫代碼去跑,也可以自己慢慢試,幸運(yùn)的是 btoa(2) 能得到 Mg== 這個(gè)字符串。所以 btoa(2)[1] 的結(jié)果就是 g了。
不過下一個(gè)問題又來了,怎樣執(zhí)行 btoa?一樣只能通過上面的 function constructor:(()=>{})['constructor']('return btoa(2)[1]')(),這次每一個(gè)字符都湊得出來。
可以結(jié)合上面的 mapping,寫一小段簡單的代碼來幫助做轉(zhuǎn)換,目標(biāo)是把一個(gè)字符串轉(zhuǎn)成沒有字符的形式:
- 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 => {
- // 先假設(shè)數(shù)字只會(huì)有個(gè)位數(shù),比較好做轉(zhuǎn)換
- 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}"`
- })
- // 加上 () 保證執(zhí)行順序
- .map(char => `(${char})`)
- .join('+')
- }
- const input = 'constructor'
- console.log(transformString(input))
輸出是:
- ((''+{})[5])+((''+{})[1])+(([][{}]+'')[1])+((![] + '')[3])+((!![] + '')[0])+((!![] + '')[1])+((!![] + '')[2])+((''+{})[5])+((!![] + '')[0])+((''+{})[1])+((!![] + '')[1])
可以再寫一個(gè)函數(shù)只轉(zhuǎn)換數(shù)字,把數(shù)字去掉:
- function transformNumber(input) {
- return input.split('').map(char => {
- // 先假設(shè)數(shù)字只會(huì)有個(gè)位數(shù),比較好做轉(zhuǎn)換
- 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)))
得到的結(jié)果是:
- ((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])
把結(jié)果丟給 console 執(zhí)行,發(fā)現(xiàn)得到的值就是 constructor 沒錯(cuò)。所以綜合以上代碼,回到剛剛那一段:(()=>{})['constructor']('return btoa(2)[1]')(),要得到轉(zhuǎn)換完的結(jié)果就是:
- const con = transformNumber(transformString('constructor'))
- const fn = transformNumber(transformString('return btoa(2)[1]'))
- const result = `(()=>{})[${con}](${fn})()`
- console.log(result)
結(jié)果很長就不貼了,但確實(shí)能得到一個(gè) g。
在繼續(xù)之前,先把代碼改一下,增加一個(gè)能直接轉(zhuǎn)換代碼的函數(shù):
- 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]'))
好了,到這里其實(shí)已經(jīng)接很近終點(diǎn)了,只有一件事還沒有解決,那就是 btoa 是 WebAPI,瀏覽器才有,node.js 并沒有這個(gè)函數(shù),所以想要做得更漂亮,就必須找到其他方式來產(chǎn)生 g 這個(gè)字符。
回憶一下一開始所提的,用 function.constructor 可以拿到 function constructor,以此類推,用 ''['constructor'] 可以拿到 string constructor,只要再加上一個(gè)字串,就可以拿到 string constructor 的內(nèi)容了!
像是這樣:''['constructor'] + '',得到的結(jié)果是:"function String() { [native code] }",一下子就多了一堆字符串可用,而我們朝思暮想的 g 就是:(''['constructor'] + '')[14]。
由于我們的轉(zhuǎn)換器目前只能支持一位數(shù)的數(shù)字(因?yàn)樽銎饋砗唵?,我們改成:(''['constructor'] + '')[7+7],可以寫成這樣:
- mapping['g'] = transform(`return (''['constructor'] + '')[7+7]`)
整合所有成果
經(jīng)歷過千辛萬苦之后,終于湊出了最麻煩的 g 這個(gè)字符,結(jié)合我們剛剛寫好的轉(zhuǎn)換器,就可以順利產(chǎn)生 console.log(1) 去除掉字母與數(shù)字后的版本:
- 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 => {
- // 先假設(shè)數(shù)字只會(huì)有個(gè)位數(shù),比較好做轉(zhuǎn)換
- 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}"`
- })
- // 加上 () 保證執(zhí)行順序
- .map(char => `(${char})`)
- .join('+')
- }
- function transformNumber(input) {
- return input.split('').map(char => {
- // 先假設(shè)數(shù)字只會(huì)有個(gè)位數(shù),比較好做轉(zhuǎn)換
- 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 個(gè)字符,成功寫出了只有:[,],(,),{,},",',+ ,!,=,>這 12 個(gè)字符的程序,并且能夠順利執(zhí)行 console.log(1)。
而因?yàn)槲覀円呀?jīng)可以順利拿到 String 這幾個(gè)字了,所以就可以用之前提過的位轉(zhuǎn)換的方法,得到任意小寫字符,像是這樣:
- 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,這個(gè)就要再想一下了。
除了這條路,還有另外一條,那就是靠編碼,例如說 '\u0043' 其實(shí)給你,看來編碼沒有辦法這樣拼起來(仔細(xì)想想發(fā)現(xiàn)滿合理的)。
除了這條路,還有另外一個(gè)方法,那就是依靠編碼,例如說 '\u0043' 其實(shí)就是大寫的 C,所以我原本以為可以通過這種方法來湊,但試了一下是不行的,像是 console.log("\u0043") 會(huì)印出 C 沒錯(cuò),但是 console.log(("\u00" + "43")) 就會(huì)直接報(bào)一個(gè)錯(cuò)誤,看來編碼沒有辦法這樣拼起來。不過仔細(xì)想想還是很合理的。
總結(jié)
最后寫出來的那個(gè)轉(zhuǎn)換的函數(shù)其實(shí)并不完整,沒有辦法執(zhí)行任意代碼碼,沒有繼續(xù)做完是因?yàn)?jsfuck
(https://github.com/aemkei/jsfuck) 這個(gè)庫已經(jīng)寫得很清楚了,在 README 里面詳細(xì)了描述它的轉(zhuǎn)換過程,而且最后只用了 6 個(gè)字符而已,真的很佩服。
在它的代碼當(dāng)中也可以看出是怎樣轉(zhuǎn)換的,大寫 C 的部分是用了一個(gè) String 上名為 italics 的函數(shù),可以產(chǎn)生<i></i>,之后再調(diào)用 escape,就會(huì)得到 %3Ci%3E%3C/i%3E,然后就得到大寫 C 了。
有些人可能會(huì)說我平時(shí)寫 BUG 寫得好好的,搞這些亂七八糟的有什么用,但這樣做的重點(diǎn)并不在于最后的結(jié)果,而是在訓(xùn)練幾個(gè)東西:
- 對于 js 語言的熟悉度,我們用了很多類型轉(zhuǎn)換和內(nèi)置方法來拼湊東西,可能有些是你從來沒聽到過的。
- 解決問題時(shí)縮小范圍的能力,從如何把字符串當(dāng)作函數(shù)執(zhí)行,再到拼湊出數(shù)字和字符串,一步步的縮小問題,子問題解決之后原問題就解決了