Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何不用英文字母與數字寫出 console.log(1)? #65

Open
aszx87410 opened this issue Dec 1, 2020 · 0 comments
Open

如何不用英文字母與數字寫出 console.log(1)? #65

aszx87410 opened this issue Dec 1, 2020 · 0 comments
Labels
JS JavaScript

Comments

@aszx87410
Copy link
Owner

前言

最近公司的同事修了一門資安相關的課,因為我本來就對資安滿有興趣的,所以就會跟同事討論一下,這也導致了我這兩週一直在研究相關的東西,都是一些以前聽過但沒有認真研究過的,例如說 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 我們就已經獲勝了。

如何湊出字串?

最後呢,就是要想辦法湊出字串了,或者換句話說,要能湊出 (()=>{})['constructor']('console.log(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 有內建兩個函式:btoaatob,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']['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 了。

有些人可能會想說平常程式碼寫得好好的,幹嘛這樣搞自己,但這樣做的重點其實不在於最後的結果,而是在訓練幾個東西,像是:

  1. 對於程式語言的熟悉度,我們用了很多型別轉換跟內建方法來湊東西,可能有些是你根本沒聽過的
  2. 解決問題,縮小範圍的能力,從如何把字串當作函式執行,再到湊出數字跟字串,一步步縮小題目,子問題解決之後原問題就解決了

總之呢,以上是我針對這一題的一些解題心路歷程,有什麼有趣的解法也歡迎留言讓我知道(例如說其他種拿到大寫字母 C 的做法),感謝!

@aszx87410 aszx87410 added the JS JavaScript label Dec 1, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
JS JavaScript
Projects
None yet
Development

No branches or pull requests

1 participant