AIS3 2022 最佳專題
今年在 AIS3 2022 研究了 Express JS 與 template engine 一起使用會有的資安問題。最後找到了 EJS ZeroDay RCE、Eta ZeroDay RCE&XSS 以及一個已經有 CVE 的 Squirrelly ZeroDay RCE。我把我負責的 Eta ZeroDay RCE 回報給作者後,兩天內完成修補,取得 CVE-2023-23630 。隨後 CVE-2022-25967 的 security advisory 被發布,那其實是同個洞 / 類似的洞。我的漏洞報告在這裡 。
感謝當時的隊友 Entroy、Lillian 以及 hurosu 的幫忙。
專題
發現
在 AIS3 課程期間,隊友 Entroy 發現了 EJS ZeroDay RCE 的洞。 那是與 Cyku 大大在 AIS3 EOF 2020 Final 出的 echo2 相關的洞。Entroy 把他那題的 write-up 給我,我了解了那個洞的原理後,也開始來挖洞了!
原理及目標挑選
那個洞只要能跟 Express JS 配在一起的 template engine 都可能會有的,問題的根源就在於 Express JS 的 template engine API 的設計。
Express JS Template engine API
下方是 Express JS 的官方文件範例,第一個是 app.js:主程式,第二個是 views/index.ntl:template file。這個程式做的就是當使用者去訪問 /
的時候,會把 views/index.ntl render 起來傳回去。
我們要關注的是 :3
app.engine
的第 2 個參數,那就是要接上 Express JS 的 template engine 本體。可以知道 template engine 可以取得的資訊有 (filePath, options, callback)
。接著我們來看一下真實世界的 template engine 是怎麼實作的。
1 | // app.js |
1 | <!-- views/index.ntl --> |
真實世界的 template engine
來看看 Eta 及 EJS 跟 Express 接合處的 code。
Eta
看到 renderFile
:8
的註解,Eta 的 renderFile
有 overload 成 (filename, dataAndOptions, cb)
及 (filename, data, options, cb)
。而前者是特別做給 Express 用的(:10
),吃的是 dataAndOptions
,所以 data
跟 options
是被混在一起的。那是 Express 預設的行為,Express 會把對他的設定一起跟 data
放到 dataAndOptions
裡面。 data
很多情況下會是使用者可控的,而 options
則否,把他混在一起不就讓這兩個東西都很容易被一起控制嗎?
https://github.com/eta-dev/eta/blob/v1.14.2/src/file-handlers.ts#L186-L239
1 | function renderFile( |
EJS
看到 :22
,又是一個把 data
跟 options
混在一起的,而那都是為了 Express 。
https://github.com/mde/ejs/blob/v3.1.9/lib/ejs.js#L441-L489
1 | exports.renderFile = function () { |
所以我們知道,很多 template engine 為了配合 Express,都會有同一種不安全的行為:讓 config
可能有機會被使用者控制。而 config
若被控制,就很有可能達到 REC,因為通常都會認為 config
是不應該被控制的,不會有太多防護機制。
所以我就直接到 Express template engines 這個官方的頁面去選目標,選到的就是 Eta 。
Eta code review
這邊我使用了 Github Security Lab 所說的常見的場景 。
這個程式只要使用者去訪問 /?favorite=Eta
,就會傳回 <h1>My favorite template engine is Eta <br></h1>
。
1 | // package.json |
1 | // app.js |
1 | <!-- views/index.eta --> |
renderFile
我們已經從上面知道 renderFile
是跟 Express 接在一起的接口,所以我們要從這裡開始看。這邊要知道 Express 會放下來這裡的參數是 (filePath, options, callback)
,會對應到 renderFile
的 (filename, data, config)
,所以 Eta 會先做一些 overload 的處理。
看到 :29,47 是處理跟 Express 接合的部分,其中 :32 getConfig(data)
回傳的是一些設定值加上 data
的 deep clone,因為 data
可控(從 app.js:11 res.render("index", req.query)
過來的),所以 renderConfig
也可控。
然後看到 :52 tryHandleCache(data, renderConfig, callback)
。記住使用者可控的東西有 data
renderConfig
,進入 tryHandleCache
。
1 | // node_modules/eta/dist/eta.cjs:869 |
tryHandleCache
看到 :6 會回傳 templateFn
,然後 :7 在去執行他。所以 handleCache
會回傳的是一個 function,如果可以把 templateFn
控制成我們想要的形狀,:7 就會去執行到。所以我們跟進 handleCache(options)
。記住 options
使用者可控。
1 | // node_modules/eta/dist/eta.cjs:816 |
handleCache
這邊注意 :3 他從 options
裡拿出了 filename
,那是 template file 的位置,可能會覺得 filename 可控,但其實不行,因為 Express 會把他蓋掉。
繼續跟進 loadFile(filename, options)
。記住 options
可控。
1 | // node_modules/eta/dist/eta.cjs:795 |
loadFile
看到 :10 的 return compiledTemplate
,那就是上面 tryHandleCache
看到會被執行的 templateFn
。
跟進 :6 的 compile
看看我們可以怎麼控制他。記住使用者可控的有 config
。
1 | // node_modules/eta/dist/eta.cjs:771 |
compile
我們知道這個 compile
會回傳的是一個 function,看看他是怎麼造出 function 的。:9 他透過 ctor
來造出 function,ctor
可從 :9 知道他是 Function
或 getAsyncFunctionConstructor()
。 Function
的用途是把字串變成 function,聽到就覺得危險。
跟進 :11 compileToString(str, options)
來看看我們可以怎麼去控制這個 function。記住使用者可控的有 options
。
1 | // node_modules/eta/dist/eta.cjs:588 |
compileToString
看到這裡,我覺得 compileToString
太危險了。那個會被轉成 function 執行的字串竟然是用字串加法去處理的,那我就隨便找一段可控的字串去植入不就好了?
來看 :8,config.varName
是我們可控的字串,他只要 config.useWith
為真就會被接上去。來實際操作一次。
1 | // node_modules/eta/dist/eta.cjs:389 |
Payload1
把上面所說的東西實作,看看 compileToString
編譯出的 res
會長怎麼樣。
看到實際用字串加法做出來的字串 res
有我們的 console.log("Ching367436")
,而且確定是在會被執行的地方,所以只要等他回傳到 tryHandleCache:8
就會被執行。結果在跑到那之前竟然就噴錯了。
1 | # payload1 |
1 | // res |
Payload1 Error
看到了錯誤訊息有有三行,:1及 :3 的很明顯的是 Eta 原生的錯誤,:5 感覺不是,上網查了一下發現是 Function
丟的錯,回想一下剛剛追到的一個地方有 Function
,那就是 compile
,所以過去看看。
1 | Eta Error: Loading file: views/index.eta failed: |
compile
revisit
看到 :9 的 ctor
的地方,第一個參數是 options.varName
,然後那在我的 payload1
裡面被改成了 console.log("Ching367436")
。來看看這樣為何會有錯。
1 | // node_modules/eta/dist/eta.cjs:588 |
ctor
上面的 ctor
相當於 Function
,把我們的東西放進 Function
看看會發生什麼事。可以看到噴的錯誤訊息跟前面噴的 Arg string terminates parameters early
是一樣的。
1 | Function('console.log("Ching367436")', 'cb', res) |
1 | // Error message |
Function
來看看 Function
的各種用法。 Function
的最後一個參數放的是 function body,前面幾個參數是要給那個 function body 的參數,可以看到最後出來的結果會回傳一個從字串變成的 function。
1 | Function('a, b', 'c', 'console.log(a, b, c)') |
1 | // output |
如果今天參數的部分被換成了不合理的值,像是下面這樣,就會噴出前面看到的錯。
1 | Function('console.log(a, b, c)', 'c', 'console.log(a, b, c)') |
1 | // output |
所以看到這邊,覺得控 options.varName
的路應該是沒辦法了,所以去找了其他可控的東西,可是也沒看到其他會被放進去的字串。所以在發表專題前就在這個裡面找了一個 ZeroDay XSS 跟已經有 CVE 的 Squirrelly ZeroDay RCE ,配上 Entroy 的 EJS ZeroDay RCE 。還是成功的拿下了最佳專題。
轉機
然後我的 payload1
其實只要 varName
的值前面加上 a=
,Function
那裡就不會噴錯了,因為 a=console.log(123)
是合理的參數。
這樣就可以執行到前面的 tryHandleCache:8
的 templateFn(data, options, cb)
,templateFn
就是 ctor
所產出的 function,所以這樣就 RCE 了。
後續
事後要把 Eta ZeroDay RCE 回報給作者,所以查了一下作者,結果看到他跟 SquirrellyJS 是同個作者,然後兩個都有類似,甚至一模一樣的 RCE 的洞。
回報
回報的是 Github 上的洞,所以我先去看了 Eta 的 security policy ,然後是空的。感覺這個也不太適合直接發 issue,最後在作者網站的 Contact 那邊看到了聯絡管道,決定用寫信的來回報。
接著就要來寫漏洞報告了,想了一下要怎麼寫,想起之前 Cyku 大大回報的同個類型的洞 。所以就照著把 proof of concept、detail、mitigation 三個部分寫了起來,最後寫出了這篇漏洞報告 。然後就寄給作者了。
隔了一天後 Eta 推出了 v2.0.0 ,Eta 發佈了 security advisory 。
然後收到了作者的回信。
又隔了兩天 Github 發給了這個洞 CVE-2023-23630 。
同一天出現了 CVE-2022-25967 的 security advisory 。所以可能有人已經回報過這個洞了。在回報這個洞之前,我已先查過了作者,他因為有事,兩年無法維護開源專案,而 CVE-2022-25967 正是那個期間的事情,所以可能才會被作者略過。我是挑在作者回來的時候回報的,所以作者馬上就修好這個洞了。
來看看他修補的 commit ,做的修改就是不再把 renderFile
的 data
merge 進 renderConfig
裡面,所以 renderConfig
就變成了使用者不可控的東西,所以漏洞就確實被從根源修補好了。
Timeline
2023/01/26: 回報漏洞給作者。
2023/01/28: Eta 釋出 v2.0.0 修補了漏洞,發布 security advisory 。
2023/01/30: Github 發給了這個漏洞 CVE-2023-23630 。
2023/01/30: CVE-2022-25967 的 security advisory 被發布了。
2023/04/08: 我把漏洞報告 公開。
修補後的問題
作者把洞修補好之後,就有東西開始壞掉了。
2.0.0 breaks Express.js res.render views configuration 發生的原因在於 Eta 不會再從 Express 放下來的 data
去找 config
了。所以作者在文檔中把設定 template file 資料夾的的地方做了改動,也就是我畫起來的地方。來看看發生了什麼事。
views
看到原本的 app.set("views", "./views")
被改成 eta.configure({ views: "./views", cache: true })
。前者是設定了 Express 的 template file 的資料夾,不過到了 v2.0.0,Eta 不會再吃 Express 傳下來的這項資訊,所以作者要大家改成直接設定在 Eta 上面,Eta 才吃得到設定。可是這樣會變成 Express 那邊壞掉,因為 Express 也需要這項資訊。
1 | // Change THIS: |
1 | // To THIS: |
解決修補後的問題
作者提出了兩個解決方案 。
第一個方案是 Eta 再去從 Express 傳下來的東西去拿這項資訊,不過這被我否決了,因為這樣做在某些情況下會可以 RCE 。
第二個方案是叫使用者 Express 及 Eta 兩邊都設定一次同樣的東西,變成下面這樣。雖然比較麻煩,但這樣比較安全。我們最後決定採用這個,所以後來 v2.0.0 的 release note 更新成了現在的樣子。
我認為 Express 一開始在做 template engine 的接口時就該讓 data
config
分開,才不會造成現在那麼麻煩,以及引發這一類型問題。
1 | // To THIS: |
- Title: AIS3 2022 最佳專題
- Author: Ching367436
- Created at : 2023-04-08 15:40:39
- Link: https://blog.ching367436.me/ais3-2022-最佳專題/
- License: This work is licensed under CC BY-NC-SA 4.0.