4

从一道毫无人性的刁钻面试题说起

 3 years ago
source link: https://segmentfault.com/a/1190000040098530
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

在 JavaScript 中,你可以不用英文字母与数字,就执行 console.log(1) 吗?

换句话说,就在于代码中不能出现任何英文字母(a-zA-Z)与数字(0-9),除此之外(各种符号)都可以。执行式码之后,会执行 console.log(1),然后在控制台中输出 1

如果你想到可以用什么库或服务之类的东西做到,别急着说出答案。先自己想一下,看看有没有办法自己写出来。如果能从零开始自己写出来,就代表你对 js 这个语言以及各种自动类型转换应该是很熟悉的。

分析几个关键点

要能成功执行题目所要求的的 console.log(1),必须要完成几个关键点:

  1. 找出执行代码的方法
  2. 如何不用字母与数字得出数字的方法
  3. 如何不用字母与数字得到字母的方法

只要这三点都解决了,就能达成题目的要求。

先解决第一点:找出执行代码的方法

找出执行代码的方法

直接 console.log 是不可能的,因为就算你用字符串拼出 console,你也沒办法像 PHP 那样拿字符串来执行函数。

那 eval 呢?evali 里面可以放字符串,可以是就可以。问题是我们也没法使用 eval,因为不能用英文字母。

还有什么方法呢? 还可以用 function constructor: new Function("console.log(1)") 来执行,但问题是我们也不能用 new 这个关键字,所以乍一看也不行,不过不需要 new 也可以,只用 Function("console.log(1)") 就可以创建一个能够执行特定代码的函数。

所以接下来的问题就变成了怎样才能拿到 function constructor,只要能拿到就有机会

在 JS 中可以用 .Constructor 拿到某个对象的构造函数,例如 "".constructor 就会得到:ƒ String() { [native code] },如果你有一个函数,就能拿到 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 的强制多态,如果你有看过 js 类型转换的文章,或许会记得 {} + [] 可以得出 0 这个数字。

假设你不知道这个,我来解释一下:利用 ! 这个运算符,可以得到 false,例如 1[] 或者 !{} 都可以得出 false。然后两个 false 相加就可得到 0![] + ![] ,以此类推,既然 ![]false,那前面再加一个 !!![] 就是 true,所以 ![] + !![] 就等于 false + true,也就是 0 + 1,结果就是 1

或者用更简短的方法,用来 +[] 也可以利用自动类型转换得到 0 这个结果,那么 +!![] 就是 1

有了 1 之后,就可以得到所有数字了,只要一直不断暴力相加就行了,如果不想这样做,也可以利用位运算 << >> 或者是乘号,比如说要凑出 8,就是 1 << 3,或者是 2 << 2,要凑出 2 就是(+!![])+(+!![]),所以 (+!![])+(+!![]) << (+!![])+(+!![]) 就是 8,只需要四个 1 就行了,不需要自己加 8 次。

不过现在可以先不考虑长度,只需要考虑能不能凑出来就行了,只要能得出 1 就足够了。

如何得到字符串

最后就是要想办法凑出字符串了,或者说要得到 (()=>{})['constructor']('console.log(1)')() 中的每一个字符。

怎样才能得到字符呢?答案是和数字一样,即强制多态

上面说过 ![] 可以得到 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 有两个内置函数:btoaatobbtoa 是把一个字符串编码为 base64,例如 btoa('abc') 会得到 YWJj,然后再用 atob('YWJj') 解码就会得到 abc

只要想办法让 base64 编码后的结果有 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']['fromCharCode'],就可以拿到任意字符。可是在这之前要先想办法拿到大写的 C,这个就要再想一下了。

除了這條路,還有另外一條,那就是靠編碼,例如說 '\u0043' 其實就是大寫的 C 了,所以我原本以為可以透過這種方法來湊,但我試了一下是不行的,像是 console.log("\u0043") 會印出 C 沒錯,但是 console.log(("\u00" + "43")) 就會直接噴一個錯誤給你,看來編碼沒有辦法這樣拼起來(仔細想想發現滿合理的)。

除了这条路,还有另外一个方法,那就是依靠编码,例如说 '\u0043' 其实就是大写的 C,所以我原本以为可以通过这种方法来凑,但试了一下是不行的,像是 console.log("\u0043") 会印出 C 没错,但是 console.log(("\u00" + "43")) 就会直接报一个错误,看来编码没有办法这样拼起来。不过仔细想想还是很合理的。

最后写出来的那个转换的函数其实并不完整,没有办法执行任意代码码,没有继续做完是因为 jsfuck 这个库已经写得很清楚了,在 README 里面详细了描述它的转换过程,而且最后只用了 6 个字符而已,真的很佩服。

在它的代码当中也可以看出是怎样转换的,大写 C 的部分是用了一个 String 上名为 italics 的函数,可以产生 <i></i>,之后再调用 escape,就会得到 %3Ci%3E%3C/i%3E,然后就得到大写 C 了。

有些人可能会说我平时写 BUG 写得好好的,搞这些乱七八糟的有什么用,但这样做的重点并不在于最后的结果,而是在训练几个东西:

  1. 对于 js 语言的熟悉度,我们用了很多类型转换和内置方法来拼凑东西,可能有些是你从来没听到过的。
  2. 解决问题时缩小范围的能力,从如何把字符串当作函数执行,再到拼凑出数字和字符串,一步步的缩小问题,子问题解决之后原问题就解决了

173382ede7319973.gif


本文首发微信公众号:前端先锋

欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章


欢迎继续阅读本专栏其它高赞文章:



Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK