PicoCTF Web Exploitation Page 1 Writeups

Here’s some quick notes I wrote doing the first page of PicoCTF web exploitation challenges


Challenge title here is a bit of a give away, just make a HEAD request to the server and it returns the flag


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 = ""

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:


    if "pico" in content:

Didn’t bother to write any parsing code or anything since the response was so small.


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

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) 
            else bennita.push(bennita.shift());
    } catch (winrey) {
}(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
!" ! "q!# #
!% $ %q!&
!( ' (q!) & )k!*
!+ +

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.


We can then decode it again to get some garbled text that looks a bit more like the flag. but it’s probably not…


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 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.


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.


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"])) {
                } else {
                    echo "MD5 hashes do not match!";
            } else {
                echo "Files are not different!";
        } else {
            echo "Not a PDF!";
    } else {
        echo "File too large!";

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 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
Date: 2018
DNT: 1
Accept-Language: sv


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 => {
    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).

Back to Frontpage