tomek7667

corCTF 2025 - control - 36 solves

control - misc

author: chop0

  • Challenge description:

Design a controller for a digital power supply!

nc ctfi.ng 32727

  • Number of solves: 36
  • Points: 159

attachments:

The Challenge

Basically the ctrl.py file contains a simulation of an electrical control system with some noise. It loads our WASM module and check if a our code can track the target waveform close enough. If our code does well enough, we will receive the flag:

mse = np.mean((x.T[1][5:-1] - target[:len(t)][5:])**2)
if mse < 0.01:
    with open("flag.txt", "r") as f:
        print(f.read().strip())

The system setup is as follows:

LOAD_RESISTANCE = 5
SOURCE_RESISTANCE = 0.01
SOURCE_VOLTAGE = 15

C1 = 47e-6
C2 = 47e-6
L = 88e-6

DT = 0.0001
N = 1000

These constants represent two capacitors marked (C1 and C2), an inductor (L) anda source with resistance and load.

The following function takes as input:

x = [vC1, vC2, iL] # The voltages through capacitors C1 and C2 with current through the inductor L.
u = [u0, u1] # these are the controller outputs

The function F(x, u) defines a differential equations of the virtual circuit and adds random noise to simulate the measurements usual uncertainty:

@numba.njit
def F(x, u):
    vC1 = x[0] + np.random.normal(0, 0.1)
    vC2 = x[1] + np.random.normal(0, 0.1)
    i_L = x[2] + np.random.normal(0, 0.1)

    v_L = vC1 * u[0] - vC2 * u[1]
    i_C1 = -u[0] * i_L + (SOURCE_VOLTAGE - vC1) / SOURCE_RESISTANCE
    i_C2 = u[1] * i_L - vC2 / LOAD_RESISTANCE

    dC1 = i_C1 / C1
    dC2 = i_C2 / C2
    di_L = (v_L ) / L

    return np.array([dC1, dC2, di_L])

Our WASM controller is making N (1000) steps calling our defined controller_step exported function, that returns a result that will be unpacked to 2 floats:

def unpack_pair_u64_to_float32(u64: int) -> tuple[float, float]:
    b = struct.pack('<Q', u64 & ((1 << 64) - 1))
    u1 = struct.unpack_from('<f', b, 0)[0]
    u0 = struct.unpack_from('<f', b, 4)[0]
    return float(u0), float(u1)

and feeding it (local_u) then to the following:

local_u = np.clip(local_u, 0, 1)
u[k] = local_u
sol = solve_ivp(lambda t, x: F(x, local_u), [k * DT, (k + 1) * DT], x_prev)
x[k + 1] = sol.y.T[-1]
k += 1

So our controller_step is taking sp, vC1, vC2 and iL as arguments.

Our target is to follow:

target = np.abs(np.sin(2 * np.pi * 60 * np.arange(N) * DT))

which is a 60 Hz sine wave wrapped in an absolute value.

The solution

So now that we know what is our goal with our WASM module, we need to define the controller_step function. In wasm, it will look as follows:

(controller_step (param f32 f32 f32 f32) (result i64))

If you don’t know WASM/WAT I strongly recommend to read mozilla’s guide to WebAssembly. We can compile the WebAssemblyText (WAT) into WASM with the wat2wasm tool (shock!) to which you can find precompiled binaries here. When you have the binary in your path, my solution script takes the solve.wat file, compiles it and sends to the netcat server.

This is the script that does the compiling, connecting and sending the payload:

from pwn import remote
import base64
import subprocess

HOST = "ctfi.ng"
PORT = 32727

def build_wasm(wat_file = "solve.wat") -> bytes:
    wasm_file = "solve.wasm"
    subprocess.check_call(["wat2wasm.exe", wat_file, "-o", wasm_file])
    with open(wasm_file, "rb") as f:
        return f.read()

def main():
    wasm_bytes = build_wasm()
    payload = base64.b64encode(wasm_bytes)

    io = remote(HOST, PORT, level="error")
    io.recvuntil(b":")
    io.sendline(payload)
    io.interactive()

if __name__ == "__main__":
    main()

And the actual WebAssemblyText, which is more and more often seen on CTFs (sekai ctf extreme example writeup), solve.wat will look as follows to follow the target function:

(module
  (type (func (param f32 f32 f32 f32) (result i64)))
  (global $integral (mut f32) (f32.const 0.0))

  (func $controller_step (export "controller_step") (type 0)
    (param $sp f32) (param $x0 f32) (param $x1 f32) (param $x2 f32)
    (result i64)
    (local $err f32) (local $int f32) (local $ff f32) (local $u f32)

    ;; err = sp - x1
    local.get $sp
    local.get $x1
    f32.sub
    local.set $err

    ;; int = clamp(global_integral + ki*err, -0.5, 0.5)
    global.get $integral
    local.get $err
    f32.const 0.02    ;; KI
    f32.mul
    f32.add
    f32.const -0.5
    f32.max
    f32.const 0.5
    f32.min
    local.set $int

    ;; save back to global
    local.get $int
    global.set $integral

    ;; ff = sp * inv_vin
    local.get $sp
    f32.const 0.06666667   ;; 1/15
    f32.mul
    local.set $ff

    ;; u = clamp(kp*err + int + ff, 0.0, 1.0)
    local.get $err
    f32.const 0.3          ;; KP
    f32.mul
    local.get $int
    f32.add
    local.get $ff
    f32.add
    f32.const 0.0
    f32.max
    f32.const 1.0
    f32.min
    local.set $u

    ;; pack (u, int) into i64
    local.get $u
    i32.reinterpret_f32
    i64.extend_i32_s
    local.get $int
    i32.reinterpret_f32
    i64.extend_i32_s
    i64.const 32
    i64.shl
    i64.or
  )
)

This is a function called 1000 times by the server, and it will pack the two numbers into a single i64 to be unpacked later by the server’s unpack_pair_u64_to_float32.

After running the solve.py with the solve.wat in the same directory, with the wat2wasm binary in path, we will see:

> python .\solve.py

corctf{l@yers_0f_c0ntrol_are_fun!}

:)