Here’s some quick notes I wrote doing the first page of PicoCTF web exploitation challenges
GET aHEAD
Challenge title here is a bit of a give away, just make a HEAD request to the server and it returns the flag
Cookies
Slightly annoying challenge, was fairly easy to see that the check endpoint looked at a cookie called name
and setting it manually to a value would give you a different type of cookie (the snack) every time.
Wrote a basic enumeration script to do it, no special value derived from elsewhere I could see, probably this challenge was to make you write a script
import requests
url = "http://mercury.picoctf.net:17781/check"
for i in range(0, 30):
cookies = {"name": str(i)}
resp = requests.get(url, cookies=cookies)
content = str(resp.content)
if "Not very special" in content:
continue
print(content)
if "pico" in content:
break
Didn’t bother to write any parsing code or anything since the response was so small.
Insp3ct0r
Cookie is just in the files that make up the webpage in 3 different places (HTML, CSS, JavaScript): very simple challenge to just view source / load up each document and find the parts of the flags in the code.
Scavenger Hunt
Similar to the previous, start on HTML, go to CSS, check javascript and there’s a hint! It points to robots.txt which has a hint also about apache. Forgot what the file was called immediately but after some time go to .htaccess, this one has a hint about Mac, obvious thought was to try __MACOSX
(that pesky folder) there, which doesn’t seem to work, I also tried .DS_Store
which did work! There were all 5 parts of the file.
Some Assembly Required
Here we are presented with an input box and nothing else: we can notice we’ve downloaded a javascript file which is obsfucated:
const _0x402c=['value','2wfTpTR','instantiate','275341bEPcme','innerHTML','1195047NznhZg','1qfevql','input','1699808QuoWhA','Correct!','check_flag','Incorrect!','./JIFxzHyW8W','23SMpAuA','802698XOMSrr','charCodeAt','474547vVoGDO','getElementById','instance','copy_char','43591XxcWUl','504454llVtzW','arrayBuffer','2NIQmVj','result'];const _0x4e0e=function(_0x553839,_0x53c021){_0x553839=_0x553839-0x1d6;let _0x402c6f=_0x402c[_0x553839];return _0x402c6f;};(function(_0x76dd13,_0x3dfcae){const _0x371ac6=_0x4e0e;while(!![]){try{const _0x478583=-parseInt(_0x371ac6(0x1eb))+parseInt(_0x371ac6(0x1ed))+-parseInt(_0x371ac6(0x1db))*-parseInt(_0x371ac6(0x1d9))+-parseInt(_0x371ac6(0x1e2))*-parseInt(_0x371ac6(0x1e3))+-parseInt(_0x371ac6(0x1de))*parseInt(_0x371ac6(0x1e0))+parseInt(_0x371ac6(0x1d8))*parseInt(_0x371ac6(0x1ea))+-parseInt(_0x371ac6(0x1e5));if(_0x478583===_0x3dfcae)break;else _0x76dd13['push'](_0x76dd13['shift']());}catch(_0x41d31a){_0x76dd13['push'](_0x76dd13['shift']());}}}(_0x402c,0x994c3));let exports;(async()=>{const _0x48c3be=_0x4e0e;let _0x5f0229=await fetch(_0x48c3be(0x1e9)),_0x1d99e9=await WebAssembly[_0x48c3be(0x1df)](await _0x5f0229[_0x48c3be(0x1da)]()),_0x1f8628=_0x1d99e9[_0x48c3be(0x1d6)];exports=_0x1f8628['exports'];})();function onButtonPress(){const _0xa80748=_0x4e0e;let _0x3761f8=document['getElementById'](_0xa80748(0x1e4))[_0xa80748(0x1dd)];for(let _0x16c626=0x0;_0x16c626<_0x3761f8['length'];_0x16c626++){exports[_0xa80748(0x1d7)](_0x3761f8[_0xa80748(0x1ec)](_0x16c626),_0x16c626);}exports['copy_char'](0x0,_0x3761f8['length']),exports[_0xa80748(0x1e7)]()==0x1?document[_0xa80748(0x1ee)](_0xa80748(0x1dc))[_0xa80748(0x1e1)]=_0xa80748(0x1e6):document[_0xa80748(0x1ee)](_0xa80748(0x1dc))[_0xa80748(0x1e1)]=_0xa80748(0x1e8);}
Here we can do some deobsfucation using deobfucate.io
const izen = ["value", "2wfTpTR", "instantiate", "275341bEPcme", "innerHTML", "1195047NznhZg", "1qfevql", "input", "1699808QuoWhA", "Correct!", "check_flag", "Incorrect!", "./JIFxzHyW8W", "23SMpAuA", "802698XOMSrr", "charCodeAt", "474547vVoGDO", "getElementById", "instance", "copy_char", "43591XxcWUl", "504454llVtzW", "arrayBuffer", "2NIQmVj", "result"];
const deelilah = function (eirinn, adiline) {
eirinn = eirinn - 470;
let hulon = izen[eirinn];
return hulon;
};
(function (bennita, safwana) {
const lamaar = deelilah;
while (true) {
try {
const lajayceon = -parseInt(lamaar(491)) + parseInt(lamaar(493)) + -parseInt(lamaar(475)) * -parseInt(lamaar(473)) + -parseInt(lamaar(482)) * -parseInt(lamaar(483)) + -parseInt(lamaar(478)) * parseInt(lamaar(480)) + parseInt(lamaar(472)) * parseInt(lamaar(490)) + -parseInt(lamaar(485));
if (lajayceon === safwana)
break;
else bennita.push(bennita.shift());
} catch (winrey) {
bennita.push(bennita.shift());
}
}
}(izen, 627907));
let exports;
(async () => {
const tanzi = deelilah;
let germain = await fetch(tanzi(489)), ariadna = await WebAssembly[tanzi(479)](await germain[tanzi(474)]()), liannah = ariadna[tanzi(470)];
exports = liannah.exports;
})();
function onButtonPress() {
const kennita = deelilah;
let antaja = document.getElementById(kennita(484))[kennita(477)];
for (let kiari = 0; kiari < antaja.length; kiari++) {
exports[kennita(471)](antaja[kennita(492)](kiari), kiari);
}
exports.copy_char(0, antaja.length), exports[kennita(487)]() == 1 ? document[kennita(494)](kennita(476))[kennita(481)] = kennita(486) : document[kennita(494)](kennita(476))[kennita(481)] = kennita(488);
}
Clear as mud…
I then noticed in the izen
array a suspicious string ./JIFxzHyW8W
which was definitely worth a try as a file that we downloaded.
This looks like a WASM file, what if I just do the easy thing here?
~/downloads $ strings JIFxzHyW8W
memory
__wasm_call_ctors
strcmp
check_flag
input
copy_char
__dso_handle
__data_end
__global_base
__heap_base
__memory_base
__table_base
j!
F!!A
!" ! "q!# #
!% $ %q!&
!( ' (q!) & )k!*
!+ +
q!
+picoCTF{51e513c498950a515b1aab5e941b2615}
Okay this probably isn’t the intended solution, but hey it worked!
More Cookies
The first request we make to the server we get
Set-Cookie: auth_name=aXcrMHpPTmpXNytWSXRyUmIxc21OS0dLNzVpUVFzMkNCN0Fqa2FqZVNYeW9YOE5qRm5LdzhPWGh5QS9uZTE2ZWFoTnh4Nm16ZDQvTUlXV0JtbVlzZWxtcC9WV3ZQbTRJQVV1NjdmV3hEeGg3NzVOV2VqOFZuT2hxaG1yRXlMd20=; Path=/
Looks like auth_name
is something we want to mess with
There’s a reset button that generates new cookies too here.
I tried the /search endpoint with a POST
request and got a session and auth_name cookie, then when I go to the index endpoint I get a ‘Unauthenticated search’ message.
Also when I made a POST
request I notice there are lots of GET
requests after that seem to try to reset my auth_name
I think the auth_name
is base64 because of the characters
Decoding the above is not helpful but it is valid.
iw+0zONjW7+VItrRb1smNKGK75iQQs2CB7AjkajeSXyoX8NjFnKw8OXhyA/ne16eahNxx6mzd4/MIWWBmmYselmp/VWvPm4IAUu67fWxDxh775NWej8VnOhqhmrEyLwm
We can then decode it again to get some garbled text that looks a bit more like the flag. but it’s probably not…
c["o[&4B͂#I|_cr{^jqǩw!ef,zYU>nK{Vz?jjȼ&
The hint mentions homomorphic encryption and CBC is capitalised in the text (I don’t like these sorts of hints, please put them in the actual question)
Luckily I took a cryptography module at university so know what CBC mode is: Cypher Block Chaining
There’s no real guidance past this point: something that’s a bit annoying with some of these PicoCTF challenges.
I read that there is potentially a bit that enables admin from here https://github.com/HHousen/PicoCTF-2021/tree/master/Web Exploitation/More Cookies
And points towards doing a bit-flip attack with the auth_name
cookie. This is pretty poor honestly: I’m not sure how you would get this information (that there is a single bit that enables admin ???)
This Stackoverflow post linked from above shows you how to do attack, even with a code example:
def bitFlip(pos, bit, data):
raw = b64decode(data)
list1 = list(raw)
list1[pos] = chr(ord(list1[pos])^bit)
raw = ''.join(list1)
return b64encode(raw)
Overall this was a bad challenge but could have been good if there was more guidance as to what we were actually looking for: doing ‘logon’ might have helped but likely not.
The solution is to just the the above but make a request with the cookie auth_name
set to each bit you try for the whole length of the decoded message and every possible bit. Overall unsatisfying brute-force.
Where are the Robots
Weirdly easy for 100 points, especially compared to More Cookies (which was 90)
Just need to navigate to the robots.txt
file and then you can see a html file with the flag in it is listed.
Logon
Again weirdly easy compared to the previous cookie challenges: there’s a cookie called “admin” that gets set to false when you log in. Set it to true and hit /flag
and you will get the flag.
dont-use-client-side
Find this chunk of JS in the HTML for the page you’re linked to:
function verify() {
checkpass = document.getElementById("pass").value;
split = 4;
if (checkpass.substring(0, split) == 'pico') {
if (checkpass.substring(split*6, split*7) == '706c') {
if (checkpass.substring(split, split*2) == 'CTF{') {
if (checkpass.substring(split*4, split*5) == 'ts_p') {
if (checkpass.substring(split*3, split*4) == 'lien') {
if (checkpass.substring(split*5, split*6) == 'lz_b') {
if (checkpass.substring(split*2, split*3) == 'no_c') {
if (checkpass.substring(split*7, split*8) == '5}') {
alert("Password Verified")
}
}
}
}
}
}
}
}
else {
alert("Incorrect password");
}
}
Fairly obvious bit of JS substring checking, just read the code in order of the splits 0 → split → split*2 etc. and you can recover the flag.
It is my Birthday
Here you get two upload boxes to upload a PDF, the implication is that you have to upload two PDF files that are different (how?) with the same MD5 hash. Obviously this is reminiscent of the shattered SHA-1 collision.
There are existing MD5 collisions but they are not PDFs!
The famous pair are the hello
and erase
binaries. What can we do to make them look like PDFs? Probably the filename is the only thing checked here, I’d doubt for a low-point challenge we’d get a very advanced check.
Sure enough with hello.pdf
and erase.pdf
uploaded we get a flag
Quite cool is that it dumps out the php:
if (isset($_POST["submit"])) {
$type1 = $_FILES["file1"]["type"];
$type2 = $_FILES["file2"]["type"];
$size1 = $_FILES["file1"]["size"];
$size2 = $_FILES["file2"]["size"];
$SIZE_LIMIT = 18 * 1024;
if (($size1 < $SIZE_LIMIT) && ($size2 < $SIZE_LIMIT)) {
if (($type1 == "application/pdf") && ($type2 == "application/pdf")) {
$contents1 = file_get_contents($_FILES["file1"]["tmp_name"]);
$contents2 = file_get_contents($_FILES["file2"]["tmp_name"]);
if ($contents1 != $contents2) {
if (md5_file($_FILES["file1"]["tmp_name"]) == md5_file($_FILES["file2"]["tmp_name"])) {
highlight_file("index.php");
die();
} else {
echo "MD5 hashes do not match!";
die();
}
} else {
echo "Files are not different!";
die();
}
} else {
echo "Not a PDF!";
die();
}
} else {
echo "File too large!";
die();
}
}
We can see that the PHP does a proper md5 check but also relies on $_FILES["file"]["type"]
which isn’t accurate to the magic binary string at the start of these files but just cares about what the input element throws out.
For example a pdf starts like %PDF-1.4
but my hello.pdf
starts with ELF
! That’s not very useful.
Who are you?
Opening the webpage says that “Only people who use the official PicoBrowser are allowed on this site!”, fine we’ll set the useragent to PicoBrowser
the we are then told “I don’t trust users visiting from another site.” I tried setting the referer in the edit request function of Firefox and it wouldn’t let me send it! Time to install something that lets me send junk requests to web servers I guess.
After installing Insomnia and setting Referer
to http://mercury.picoctf.net:1270
we get “Sorry, this site only worked in 2018.”, great lets set the Date
header to 2018
now we get “I don't trust users who can be tracked.”, okay we can set the DNT
(now defunct) header to 1
, surely we are getting close now?
“This website is only for people from Sweden.” great… I’m not sure if there are any official flags for geo codes? Googling I see X-Geo
and set that to SE
which doesn’t seem to work I also tried X-Geo-Country-Code
with value SE
which didn’t work either.
I also tried Accept-Language
and Content-Language
as sv
and swe
and similarly no luck.
I then tried X-Forwarded-For
with a Swedish IP, which got to the flag!
In the end I ended up with these headers:
User-Agent: PicoBrowser
Referer: http://mercury.picoctf.net:1270
Date: 2018
DNT: 1
Accept-Language: sv
X-Forwarded-For: 103.57.72.0
Login
Viewing index.js we find a minified (but not obsfucated) piece of Javascript
We can see that it called btoa
on the input boxes and we need to match username with those strings:
(async () => {
await new Promise(e => window.addEventListener("load", e)), document.querySelector("form").addEventListener("submit", e => {
e.preventDefault();
const r = {u: "input[name=username]", p: "input[name=password]"}, t = {};
for (const e in r) t[e] = btoa(document.querySelector(r[e]).value).replace(/=/g, "");
return "YWRtaW4" !== t.u ? alert("Incorrect Username") : "cGljb0NURns1M3J2M3JfNTNydjNyXzUzcnYzcl81M3J2M3JfNTNydjNyfQ" !== t.p ? alert("Incorrect Password") : void alert(`Correct Password! Your flag is ${atob(t.p)}.`);
});
})();
btoa
converts a string into a base64 representation, we can call atob
to reverse this, call atob(YWRtaW4)
and we see the username is admin
the other string cGljb0NURns1M3J2M3JfNTNydjNyXzUzcnYzcl81M3J2M3JfNTNydjNyfQ
decodes to the password (which is the flag).