I got first blood for a challenge called “safelist” in SekaiCTF 2022, it’s a challenge about xsleaks and request timing in particular, here is my writeup.
Challenge description:
Safelist™ is a completely safe list site to hold all your important notes! I mean, look at all the security features we have, we must be safe!
Source code: https://github.com/project-sekai-ctf/sekaictf-2022/tree/main/web/safelist
The website is simple, you can either create or delete a note, also there is an admin bot to visit the URL you provided.
What’s unique about this challenge is that it sets a lot of security headers:
1 | app.use((req, res, next) => { |
Also, the note content is sanitized by DOMPurify.sanitize()
before assigning it to innerHTML, so there is no way to do XSS.
There is another special feature which caught my eye:
1 | app.post("/create", (req, res) => { |
When you create a note, the whole list will re-order. The flag format is ^SEKAI{[a-z]+}$
. Knowing this, we can create a note before or after the flag.
It’s pretty helpful for performing xsleak.
For example, if we create a note like this([CHAR]
can be anything between A to Z): [CHAR]<canvas height="1200px"></canvas><div id="scroll"></div>
, then we update the location to #scroll
, if [CHAR]
is before the flag, no scroll occurs.
A scroll occurs if [CHAR]
is after the flag. If we can detect this behavior, we can leak the flag char by char.
But the problem is there is a Cross-Origin-Opener-Policy
header. By setting this header, we can’t access the opened window:
1 | var w = window.open('https://safelist.ctf.sekai.team/') |
Basically, we lost all the controls on the opened window, we can just open it but can’t close or update its location.
Then, I came up with another way to do the leak.
Loading image
Lazy loading image is a common technique for xsleaks, my idea is similar to the above, but just replace the scroll fragment with a lazy-loading image tag.
We need to find a threshold to achieve something like this.
The image is loaded when the note I created is before the flag.
When the note is after the flag, image is not load because of loading=lazy
attribute.
The note content is something like this: A<br><canvas height="1850px"></canvas><br><img loading=lazy src=/?img>
1850px
is a well-crafted number, you can find this threshold by testing it locally, and it’s worth mentioning that the threshold is different for normal and headless browsers. You need to test it on the headless browser to find the correct number(3350px in this case).
What can we do next?
If the image is cached, we can detect it by timing the request, but unfortunately, there is a Cache-Control: no-store
header, so there is no cache at all.
How about using the concurrent limit to detect? In Chrome, you can only send 6 concurrent requests for each host. The rest will be pending hence takes more time to complete.
So, we can send another request from our page, if images are loaded, our request should take more time.
This should work according to the solution from @terjanq, but at that time I used fetch
for timing the request, and somehow the concurrent limit was not applied(maybe the partition key is different).
You can watch these two videos for details:
- https://www.youtube.com/watch?v=ixyMZlIcnDI (Use script element to send request)
- https://www.youtube.com/watch?v=15CJQ9nzrxs (Use fetch to send request)
But it’s okay. We can still leverage other things.
Make server side busy?
The server is running on Node.js, and we know that Node.js is single-threaded. What does this mean? This means it can only process one request at the same time.
So, if you have time-consuming work, it may cause performance issues. When the server does some heavy job, the main thread is blocked, so the server can not process all other requests.
Even if no endpoint does heave calculation in the challenge, we can still block the main thread for a bit when we send many requests.
To sum up, the idea is like this:
- We create a note that starts with a CHAR(a-z)
- We keep using fetch to measure the response time
- If response time is less than the threshold, it means the CHAR is after the flag, so images are not load
- Otherwise, CHAR is before the flag
- The goal is to find a CHAR that
time(CHAR)>threshold && time(CHAR+1)<threshold
Below is the screenshot for trying two different chars.
For the note that starts with SEKAI{z
, the load time is 0.9s (for 30 requests), which means the images are not loaded.
For SEKAI{m
, the load time is 1.7s which is much more than z
, which means the correct char is between m to y.
By the way, we can use binary search to speed up the searching part.
Exploit:
https://gist.github.com/aszx87410/155f8110e667bae3d10a36862870ba45
1 | <!DOCTYPE html> |