卧槽,不用英文和数字我居然写出了 console.log(1)?
共 16174字,需浏览 33分钟
·
2021-03-07 04:04
文章转载自港台作者:huli,已翻译为简体。
前言
最近公司的同事修了一门资安相关的课,因为我本来就对资安满有兴趣的,所以就会跟同事讨论一下,这也导致了我这两周一直在研究相关的东西,都是一些以前听过但没有认真研究过的,例如说 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
是不可能的,因为你就算你用字串拼出 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
我们就已经获胜了。
如何凑出字串?
可是我们要怎么样才能凑出字符呢?
关键跟数字一样,就是 coercion!
上面有讲过 ![]
可以拿到 false
,那你后面再加一个字串:![] + ''
,不就可以拿到 "false"
了吗?那这样我们就可以拿到 a, e, f, l, s
这五个字符。举例来说,(![] + '')[1]
就是 a
,为了方便纪录,我们来写一点小程式吧!
const mapping = {
a: "(![] + '')[1]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
l: "(![] + '')[2]",
s: "(![] + '')[3]",
}
那既然有了 false
,拿到 true
也不是一件难事,!![] + ''
就可以拿到 true
,我们的程式码就可以改成:
const mapping = {
a: "(![] + '')[1]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
l: "(![] + '')[2]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
再来呢?再来一样利用转型,用 ''+{}
可以得到 "[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
。
拿到之后,我们的转换表就变得更加完整了:
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
,我们目前的转换表裡面没有这个字符。
所以一定还要再从某个地方把 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
,写一个简单的小程式来帮我们做转换,目标是把一个字串转成没有字元的形式:
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
的内容了!
像是这样:''['constructor'] + ''
,得到的结果是:"function String() { [native code] }"
,一瞬间多了堆字符串可以用,而我们朝思暮想的 g
就是:(''['constructor'] + '')[14]
。
由于我们的转换器目前只能支援一个位数的数字(因为做起来简单),我们改成:(''['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'
,就可以拿到任意字符。可是在这之前要先想办法拿到大写的 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 的做法),感谢!