卧槽,不用英文和数字我居然写出了 console.log(1)?

文章转载自港台作者: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) ,必须要完成几件事情,像是:

  1. 找出如何执行程式码

  2. 如何不用字母与数字得出数字

  3. 如何不用字母与数字得出字母

只要这三点都解开了,应该就能达成题目所要求的东西。

直接 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 也是没问题的,可是就唯独缺了 logg ,我们目前的转换表裡面没有这个字符。

所以一定还要再从某个地方把 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 有内建两个函式:btoaatobbtoa 是把一个字串 encodebase64 ,例如说 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 的做法),感谢!

最后

欢迎关注【前端瓶子君】✿✿ヽ(°▽°)ノ✿

回复「算法」,加入前端算法源码编程群,每日一刷(工作日),每题瓶子君都会很认真的解答哟!

回复「交流」,吹吹水、聊聊技术、吐吐槽!

回复「阅读」,每日刷刷高质量好文!

如果这篇文章对你有帮助,「在看」是最大的支持

》》面试官也在看的算法资料《《

“在看和转发”就是最大的支持

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值