This note records a small nftables firewall setup for a
VPS that also runs Docker.
The goal is not to write a clever universal ruleset. The goal is
simpler: keep the host input path small, expose only the
ports that should be public, and avoid fighting Docker's own NAT
rules.
The example uses documentation IP addresses:
203.0.113.10 as the admin's trusted IP
198.51.100.20 as the server IP
22222 as the SSH port
Replace them with your own values.
Basic Rules
Use the inet family so the same table can handle IPv4
and IPv6:
Docker manages its own forwarding and NAT rules. If you blindly flush
everything and then set forward to drop,
containers may lose network access or published ports may stop
working.
For a small VPS, I usually keep the boundary simple:
protect the host through the input chain
let Docker manage container NAT
expose public services through Caddy on 80/443
bind internal app ports to 127.0.0.1 when possible
Public SSH servers are constantly scanned and brute-forced. Changing
the SSH port is not real security by itself, but it reduces background
noise. fail2ban adds an actual defensive layer by watching
failed login attempts and temporarily banning abusive IP addresses.
This note records a minimal setup for SSH protection with
fail2ban and systemd logs.
Install Fail2ban
Arch Linux:
1
sudo pacman -S fail2ban
Debian/Ubuntu:
1
sudo apt install fail2ban
Enable the service:
1
sudo systemctl enable --now fail2ban
Optional: Change SSH Port
Edit the SSH daemon config:
1
sudo vim /etc/ssh/sshd_config
Set a non-default port. This article uses 22222 as an
example; replace it with your own value.
1
Port 22222
Before restarting SSH, keep your current SSH session open. A bad
config or firewall mistake can lock you out.
Validate the config:
1
sudo sshd -t
Restart SSH:
1
sudo systemctl restart sshd
Some distributions use ssh instead of sshd
as the service name:
1
sudo systemctl restart ssh
Test login from another terminal before closing the old session:
1
ssh -p 22222 user@example.com
Configure Fail2ban For SSH
Create a local jail config instead of editing the packaged
defaults:
1
sudo vim /etc/fail2ban/jail.d/sshd.local
Minimal config:
1 2 3 4
[sshd] enabled = true port = 22222 backend = systemd
If you keep SSH on the default port, use:
1
port = ssh
Restart fail2ban:
1
sudo systemctl restart fail2ban
Check Status
List enabled jails:
1
sudo fail2ban-client status
Check the SSH jail:
1
sudo fail2ban-client status sshd
View logs:
1
sudo journalctl -u fail2ban -e
On systems where fail2ban logs to a file:
1
sudo less /var/log/fail2ban.log
Unban An IP
If you accidentally ban yourself, unban the IP from another trusted
session:
1
sudo fail2ban-client set sshd unbanip 203.0.113.10
203.0.113.10 is a documentation example address. Replace
it with the real IP you need to unban.
Safer SSH Baseline
Fail2ban is only one layer. These SSH settings are usually worth
enabling too:
1 2 3
PermitRootLogin no PasswordAuthentication no PubkeyAuthentication yes
After changing SSH config, always validate and restart:
1 2
sudo sshd -t sudo systemctl restart sshd
Notes
Do not rely on port changes as the only protection.
Prefer SSH keys over passwords.
Keep an existing SSH session open while changing SSH and firewall
settings.
If a firewall is enabled, allow the SSH port before restarting
SSH.
Use /etc/fail2ban/jail.d/*.local files for local
overrides so package updates do not overwrite your changes.
This note records a minimal InspIRCd configuration for
running a small IRC server with TLS support. The example exposes a plain
client port on 6667 and a TLS client port on
6697.
The TLS setup uses the ssl_gnutls module and certificate
files stored under /etc/inspircd/cert/.
Requirements
Install InspIRCd and make sure the GnuTLS SSL module is
available:
1
sudo pacman -S inspircd
On Debian/Ubuntu-style systems, the package name may differ:
1
sudo apt install inspircd
You also need a certificate and private key. For a public domain, use
Let's Encrypt or another ACME client. The config below expects:
This is my current .gdbinit setup for binary
exploitation and reverse engineering. It loads pwndbg,
switches disassembly to Intel syntax, follows child processes after
fork, and automatically opens separate tmux
panes for disassembly, stack, backtrace, registers, and an IPython
scratch pane.
Requirements
Install these first:
gdb
pwndbg
tmux
ipython optional, only used for the scratch pane
The config assumes pwndbg is installed at:
1
/usr/share/pwndbg/gdbinit.py
If your pwndbg install path is different, change the first line of
the config.
Controls how much disassembly and stack context pwndbg prints.
Base Address Helpers
These two lines are personal scratch variables:
1 2
set $mybase1 = 0x0000555555554000 set $mybase = 0x7ffff7ffc000
They are not required. I use them as quick base-address anchors while
debugging PIE binaries or shared libraries. You can remove them or
replace them with values from piebase, vmmap,
or a leak.
Example usage:
1 2
x/10i $mybase1 + 0x1234 b *($mybase1 + 0x19e3)
Adjusting The Layout
The pane sizes are controlled by
tmux split-window -l:
Yazi is a terminal file manager written in Rust. It is fast,
keyboard-driven, and works well as a lightweight replacement for opening
a full GUI file manager when working inside a terminal or SSH
session.
This note records a minimal Linux setup: install the standalone
binary and add a shell wrapper so that exiting Yazi can change the
current shell directory.
Installation
Download the latest musl build from the official release page:
unar yazi-x86_64-unknown-linux-musl.zip cd yazi-x86_64-unknown-linux-musl
Install both binaries into a directory in $PATH:
1
sudomv yazi ya /usr/local/bin/
Check that it works:
1 2
yazi --version ya --version
Shell Wrapper
By default, a terminal file manager cannot directly change the parent
shell's current directory. Yazi solves this by writing the final
directory to a temporary file. A shell function can read that file after
Yazi exits and then cd there.
Add this function to .bashrc, .zshrc, or
the shell config you use:
When you quit Yazi, the terminal will stay in the directory you were
viewing.
Notes
yazi is the file manager itself.
ya is Yazi's helper command, used for package/plugin
management and integrations.
The musl build is convenient because it is mostly self-contained and
works on many Linux distributions.
If sudo mv fails, make sure /usr/local/bin
exists and is included in $PATH.
Update
To update, download the latest release again and replace the old
yazi and ya binaries:
1 2 3 4
wget https://github.com/sxyazi/yazi/releases/latest/download/yazi-x86_64-unknown-linux-musl.zip unar yazi-x86_64-unknown-linux-musl.zip cd yazi-x86_64-unknown-linux-musl sudomv yazi ya /usr/local/bin/
Writing to 32-bit registers (e.g., EAX) automatically
clears the upper 32 bits of the corresponding 64-bit register
(RAX). xor EAX, EAX is equivalent to
xor RAX, RAX but has a shorter encoding.
Return Value: RAX (with
RDX potentially holding high bits or extra data).
Syscall arguments: RDI,
RSI, RDX, R10, R8,
R9; syscall number in RAX.
RCX and R11 are clobbered by
syscall.
x86_32: Arguments are passed via the
stack in right-to-left order.
1 2
man syscall grep -R "__NR_execve" /usr/include/asm* /usr/include/x86_64-linux-gnu/asm* 2>/dev/null
REX.W Prefix (0x48)
The REX prefix range is 0x40-0x4F
(0100 WRXB), used to extend x86 instructions to 64-bit:
W: Set to 1 for 64-bit operations.
R, X, B: Extension for register addressing
(R8-R15).
mov RDI, RSP requires a REX prefix with W=1 →
0100 1000 = 0x48.
Stack Frame Layout
RBP (Register): Points to the bottom
of the current function stack frame. Local variables are
accessed relative to it (e.g., [RBP - 0x10]).
saved RBP (Stack Data): A backup of
the caller's RBP, used to restore the previous frame upon
return.
Prologue & Epilogue
1 2 3 4 5 6 7 8
; Prologue (Entering Function B) push RBP ; Save Caller A's RBP -> becomes saved RBP mov RBP, RSP ; Establish B's stack frame
; Epilogue (Exiting Function B) - Equivalent to 'leave; ret' mov RSP, RBP ; Clean up local variables pop RBP ; Restore Caller A's RBP ret ; Return to Caller A
In memory, bytes can represent either code or data; the CPU
distinguishes them solely based on RIP. Bytes pointed to by
RIP are executed as opcodes, while others accessed via
pointers are treated as data.
Security Mitigations
Name
Description
NX
No-eXecute bit. Hardware-level page attribute marking memory pages
as non-executable.
W^X
Write XOR Execute. Memory is either writable or executable, never
both simultaneously.
ASLR
Address Space Layout Randomization. Randomizes memory layout at
runtime.
PIE
Position Independent Executable. Randomizes the binary
base address.
canary
Stack protector. Detects buffer overflow by checking a
secret value before returning.
Checksec Reading
Mitigation
Exploitation impact
No Canary
Saved RIP overwrite is usually direct once the offset
is known.
Canary
Need leak, byte brute-force, non-return control flow, or write
primitive that skips the canary.
NX disabled
Stack/heap shellcode is viable if control can jump to it.
NX enabled
Prefer ROP, ret2libc, mprotect, mmap,
JOP/COP, or existing executable regions.
No PIE
Binary code addresses are fixed, e.g. win() / gadgets /
PLT have stable absolute addresses.
PIE enabled
Need code pointer leak, PIE base recovery, or partial
overwrite/brute force.
No RELRO
GOT is writable and can be overwritten before/after resolution.
Partial RELRO
.got.plt remains writable; lazy binding is still
present.
Full RELRO
GOT is read-only after startup; GOT overwrite is blocked.
SHSTK
Intel CET shadow stack verifies returns; classic ret
overwrite/ROP may fail.
IBT
Intel CET indirect branch tracking requires indirect-call/jump
targets to begin with endbr64.
checksec hints at the easiest path, but it is not a
proof. A binary with executable stack may still be protected by
SHSTK/IBT, input filters, unstable stack
addresses, or non-return exits.
ELF & Dynamic Linking
ELF header: architecture, entry point, program
headers.
Program headers: loader view; maps segments into
memory (LOAD, GNU_STACK,
GNU_RELRO).
Trace data flow: where user bytes land, how size is
computed, where pointers are stored, whether data is copied, validated,
freed, or printed.
Convert checks into constraints: compare constants,
printable-byte filters, checksums, jump tables, index math, bounds
checks.
Exploit mapping: choose leak/write/control-flow
primitive that matches mitigations.
Useful questions while reversing:
Is the binary stripped? If not, start with symbols. If stripped,
start with imports, strings, and cross-references.
Does the program return normally, call exit, or loop
forever? Return-address hijacking only triggers on a return path.
Does a length check use signed or unsigned comparison? Which width:
byte, word, dword, or
qword?
Are there hidden repeat/backdoor paths that re-enter the vulnerable
function before canary validation?
Does a parser use a switch/jump table? Can an out-of-range or
special directive reach extra code?
radare2 Quick Reference
1 2 3 4 5 6 7 8 9
rax2 0x28 # Hex/decimal conversion rabin2 -I ./chall # Binary info rabin2 -z ./chall # Data-section strings rabin2 -zz ./chall # All strings rabin2 -i ./chall # Imports rabin2 -e ./chall # Entry points r2 -A ./chall # Analyze and open r2 -w ./chall # Open writable for patching r2 -A -q -c "pdf @ main" ./chall # Non-interactive disassembly
Inside r2:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
aaa # Full analysis afl # List functions s main # Seek to symbol/address s - # Seek back pdf # Print current function pdf @ sym.main # Print a function pd 20 # Print 20 instructions px 64 # Hexdump iz # Strings in data sections izz # Strings in whole binary axt @ addr # Cross-references to address axf @ addr # Cross-references from address VV # Visual graph wx 909090 # Patch raw bytes (requires -w) wa nop # Assemble and patch instruction (requires -w)
Patching Notes
Patching data is safer than patching code when the target check
compares constants or file-format bytes.
For code patches, inspect instruction length first; replacing a
longer conditional branch with shorter bytes needs padding with
nop.
In non-PIE ELF, runtime VA is often near file mapping base
0x400000; still convert VA to file offset using program
headers or tooling instead of guessing.
1 2 3
rasm2 -a x86 -b 64 "nop" rasm2 -a x86 -b 64 -d "90" objdump -d -Mintel ./chall | less
pwndbg /path/to/binary # Launch start # Break at main (fails if no main symbol) starti # Break at first instruction (initialization code) entry # Break at ELF entry point info functions# List all analyzed function symbols info frame # Show current stack frame piebase # Show PIE base address vmmap # Show memory layout (r-x, rw-, etc.) breakrva 0x19e3 # Break at Relative Virtual Address (useful for PIE) stack # Show stack canary # Print and find canaries on the stack context <section> # Show specific context window checksec # Check for security features (NX, ASLR, PIE, Canary) cyclic 100 # Generate 100-byte De Bruijn sequence cyclic -l 0x6161616c # Find overflow offset using crash address retaddr # Highlight return addresses in the current stack frame rop # Simple ROP Gadget search got # Show GOT state (resolved vs unresolved) plt # View Procedure Linkage Table heap # Overview of all heap chunks and their status bins # View free lists (fastbins, unsorted, small, large, tcache) arena # View detailed structure of the main arena (malloc_state)
p $rsp# Show current stack pointer disass main # Disassemble 'main' function break *main+123 # Breakpoint at main+123
Category
Command
Stepping
n (step over), s (step into),
fin (finish), c (continue)
pwn shellcraft -l # Output format (default: hex), choose from {e}lf, {r}aw, {s}tring, {c}-style array, {h}ex string, hex{i}i, {a}ssembly code, {p}reprocessed code, escape{d} hex string pwn shellcraft amd64.linux.sh -f d
De Bruijn Sequence (Cyclic)
A De Bruijn sequence \(B(k, n)\) is
a cyclic sequence where every possible string of length \(n\) over an alphabet of size \(k\) appears exactly once.
Why it's used: When a binary crashes
due to a buffer overflow, the RIP will be
overwritten by a 4 or 8-byte chunk of the sequence. Since every chunk is
unique, cyclic_find can instantly determine the exact
offset needed for the exploit.
Crash-offset workflow:
1 2 3 4 5 6 7 8 9 10
from pwn import *
context.arch = "amd64" p = process("./chall") p.send(cyclic(300)) p.wait()
For 64-bit patterns, keep n consistent between
generation and lookup if you use non-default subsequence size.
Environment Control
Disable ASLR for Debugging: gdb
disables ASLR by default. For a clean environment, use
setarch x86_64 -R /bin/bash to spawn a shell where all
child processes (excluding SUID) have ASLR disabled.
SUID Trap: gdb cannot disable
ASLR for SUID binaries due to kernel security. Remove the
SUID bit (chmod u-s) or copy the binary to a
local directory before debugging.
Global ASLR Toggle:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
(Reset to 2 when finished).
Environment affects stack addresses. argv[0] length,
environment variables, terminal, and pwntools launching mode can shift
stack buffers. Use env={} in process() or add
a NOP sled when stack shellcode depends on approximate addresses.
Information Disclosure
Causes
String Termination Problems
C strings are null-terminated, meaning they lack
length metadata and rely on a 0x00 byte to mark the
end.
Missing Null byte: If a program reads exactly \(N\) bytes into an \(N\)-byte buffer (e.g., using
read()), it won't append a null byte.
Disclosure: Functions like
printf("%s") will continue reading past the buffer until
they hit a null byte, potentially leaking adjacent
sensitive data like a flag or canary.
Uninitialized Data (Stack
Frame Reuse)
Frame Persistence: When a function returns, its
stack frame is not cleared. The RSP simply
moves back up.
Ghost Data: If a subsequent function call allocates
a frame over the same memory area and fails to initialize its variables,
it can read or "leak" the "ghost" data left by the previous
function.
Compiler
Backstabbing (Dead Store Elimination)
The Trap: A developer might use
memset(buf, 0, size) to clear a sensitive flag
before a function returns.
DSE: If the compiler (with -O2 or
-O3) determines that buf is never accessed
again before the function returns, it may "optimize out" the
memset entirely, leaving the secret data on the
stack. Use -fno-inline or specific memory
barrier techniques to prevent this.
Format String Leaks
printf(user_input) treats user bytes as a format string.
This can leak stack/register values and, with %n, write
memory.
If win_authed(token) checks a stack/local token,
sometimes jump past the check instead of calling the function entry.
This is an offset jump. Make sure the target
instruction does not depend on skipped setup.
Canary Bypass Patterns
Direct leak: program prints past the canary because
the input overwrote its leading \x00 terminator.
Residual stack leak: another function leaves a
canary copy or secret in reused stack memory.
Recursive/retry path: trigger a path that re-enters
vulnerable code before the outer canary is checked.
Fork brute-force: child crashes do not randomize
parent canary; brute-force byte-by-byte.
Skip canary write zone: corrupt loop index,
pointer, length, or destination so writes land after the canary.
Stack canary on amd64 usually has a null low byte. Leak
reconstruction often looks like:
Despite ASLR, the relative
offset between functions and data within the same
module remains constant.
Leak a known pointer (e.g., a function address in the
GOT).
Subtract its constant offset to find the base
address.
Calculate the addresses of all other gadgets/functions.
Method 2: Partial Overwrite
(YOLO)
Memory is managed in pages (typically
0x1000 bytes). The lowest 12 bits of an address represent
the page offset and are not randomized by
ASLR.
Strategy: Overwrite only the least significant 1 or
2 bytes of the return address. This allows redirecting
execution to a different instruction within the same page (or nearby)
without knowing the randomized base. Overwriting 2 bytes usually
requires a 1/16 brute-force of the 4th nibble.
Method 3: Fork Brute-force
In a fork()-based network server, child processes
inherit the exact memory layout of the parent,
including ASLR offsets and the canary.
Strategy: Brute-force the canary or
return address byte-by-byte. If the child crashes, the
parent simply forks a new one with the same values, allowing for
infinite attempts.
Canary brute-force skeleton:
1 2 3 4 5 6 7 8 9 10 11 12 13
from pwn import *
canary = b"\x00" for i inrange(7): for guess inrange(256): io = remote("host", 1337) payload = b"A" * offset_to_canary + canary + bytes([guess]) io.send(payload) out = io.recvall(timeout=0.2) io.close() ifb"stack smashing detected"notin out andb"crash"notin out: canary += bytes([guess]) break
Method 4: ret2libc
With NX enabled, call existing libc code instead of injecting
code.
Sigreturn-oriented programming uses a fake rt_sigreturn
frame to set all registers. Useful when gadgets are scarce but you can
execute syscall with RAX = 15
(rt_sigreturn).
Avoid stack self-destruction: after ret,
RSP points just above the return address. Shellcode using
push may overwrite nearby bytes below RSP.
Place shellcode sufficiently before/after the overwritten return path or
use a large sled.
Staged Shellcode
When input length is too small, first-stage shellcode reads a larger
second stage into RWX memory or stack, then jumps there.
If the first page is made RX after input, place the patching code and
patch targets on a later still-writable page, or use a first-stage jump
over the protected region.
CET: SHSTK & IBT
Intel CET adds hardware CFI:
SHSTK (Shadow Stack): return addresses are mirrored
on a protected shadow stack. ret compares the normal stack
return address against the shadow one. A classic saved-RIP
overwrite can crash even when canary is disabled.
IBT (Indirect Branch Tracking): indirect
call/jmp targets must start with
endbr64 (f3 0f 1e fa).
notrack prefix:
notrack jmp rax can bypass IBT checks for that branch, so a
corrupted function pointer/register may jump to shellcode or arbitrary
code even when IBT is enabled.
roobet recently introduced their new game: rush hour. the objective
of the game is to successfully bet on how many cars cross an arbitrary
section of road in a given time. i have identified a highway exit on a
california traffic cam that experts predict will have no traffic during
this ctf. however, you somewhat unwisely bet that one car would take
this exit. well, now's your chance. can you hack the autonomous car RL
policy to drive to the target?
Challenge Overview
We are given a local challenge bundle containing a fixed
reinforcement-learning driving policy and a remote service at
rush-hour.challs.umdctf.io. The service accepts a small
neural network that we control, which perturbs the observation vector
seen by the fixed policy. The goal is to make the autonomous car drive
into a hidden CTF target instead of the legitimate goal.
The twist: the local simulator uses a different physics timestep than
the remote environment. An attack that works locally at one timestep may
fail entirely at another.
Given Files
The challenge directory contains:
policy.py -- the fixed driving policy
attack.py -- the attacker network definition
game.py -- the environment/game loop
observations.py -- observation generation
physics.py -- car dynamics
weights.npz -- the fixed policy weights
We do not control the main driving policy. We only
control a small attack network that adds a bounded perturbation to the
policy's 8-dimensional observation.
The environment computes observations using LEGIT_GOAL,
but the flag is awarded if the car reaches CTF_GOAL. The
whole challenge is an adversarial-control problem: make the policy think
it should do something slightly different at every timestep until it
reaches the hidden target.
The Attacker Model
From attack.py, the network constraints are:
Input dimension: 8
Hidden dimension: 16
Output dimension: 8
Per-weight absolute value bound: 10.0
Output L2 norm bound after forward pass: 0.5
The submitted .npz must contain:
W0 shape (16, 8)
b0 shape (16,)
W1 shape (8, 16)
b1 shape (8,)
The forward pass is simple:
1 2 3 4 5 6
h = np.tanh(W0 @ obs + b0) y = np.tanh(W1 @ h + b1) norm = float(np.linalg.norm(y)) if norm > MAX_DELTA_L2: y = y * (MAX_DELTA_L2 / norm) return y
The perturbation is then added to the observation before the fixed
policy runs.
Policy Inputs
From observations.py, the policy sees an 8-dimensional
vector:
Normalized speed
Normalized steer angle
Heading cosine/sine
Goal-forward and goal-right coordinates in the car frame
Log-distance-to-goal
Constant bias term
This means the attack must be state-dependent -- a fixed offset would
not work because the policy's inputs change as the car moves.
Initial Solve Strategy
The most direct approach: optimize the attack network weights
directly against the provided simulator. This is the right starting
point because:
The remote service accepts exactly this network format
The bundle includes the full local environment and fixed policy
The attack is small enough to search directly (280 parameters)
The simulator is deterministic (same seed = same result)
I built a local solver (solver.py) that:
Simulates episodes from reset
Evaluates attack candidates using the local environment
Scores candidates by:
Huge reward for reaching the CTF goal
Otherwise minimizing distance to the CTF goal
Uses an evolution-style search over attack weights
Solver Architecture
The solver uses a simple evolution strategy. The core idea: maintain
a "center" set of weights in 280-dimensional space, sample random
variations around it, run each variant through the simulator, pick the
best ones, and move the center toward them.
See the Full Solver Code section
below for the complete 205-line script.
Local Success
Running at the default dt=0.1, the search converged
quickly:
Uploading the local-winning artifact to the remote service
produced:
1
episode timed out
This was the key twist in the challenge. The remote behavior clearly
diverged from local, even though both appeared to represent the same
game.
Root Cause Investigation
Instead of guessing, I connected directly to the websocket endpoint
used by the frontend:
1
wss://rush-hour.challs.umdctf.io/ws
The frontend JavaScript bundle showed that the page renders state
messages including x, z, heading,
speed, obs, obsDelta,
goalReached, timedOut, and flag.
This allowed me to stream live state from the remote server.
What the Remote Stream
Showed
The remote server was sending state updates at a much finer time
cadence:
The artifact was not robust. It only won under a coarse fixed-step
local simulation. The finer timestep changes the car's trajectory enough
that the attack perturbations no longer steer toward the CTF goal.
This explained the remote timeout perfectly.
The Real Exploit
The real solve was:
Use the provided local simulator to understand the control
surface
Discover that the remote environment runs at a different timestep
(dt ~ 0.02)
I tested several seeds and saved multiple candidates:
remote_seed1.npz
remote_seed7.npz
remote_seed42.npz
remote_seed99.npz
All of them transferred locally under the finer timestep.
Verification
I verified each candidate at both dt=0.02 and the exact
remote cadence dt=0.019863146:
1 2 3 4 5
for seed in [1, 7, 42, 99]: arrays = load_npz(f"artifacts/remote_seed{seed}.npz") for dt in [0.02, 0.019863146]: result = evaluate_attack(arrays, dt=dt) print(f"seed={seed} dt={dt} -> goal={result['goal_reached']}")
All four seeds succeeded at both timesteps.
Final Remote Submission
Submitting remote_seed99.npz to the websocket and
waiting for server-side state updates eventually produced:
We trained two normalizing flow models on similar data
distributions. Can you find an input that one model thinks is highly
likely and the other thinks is not? Submit a perturbation around the
reference window satisfying: L_inf <= 0.08, log q1(x) - log q0(x)
>= 30, log q1(x) >= threshold, and log|det J_f1|(x) within a
calibrated band.
We are given two pretrained normalizing flow models
(flow_0.pt and flow_1.pt) along with a
reference input window (reference_window.npy). Normalizing
flows are generative models that define a bijective mapping between a
simple base distribution (usually a standard Gaussian) and a complex
data distribution. They allow exact log-likelihood evaluation via the
change-of-variables formula.
The challenge asks us to find a perturbation \(x_{\text{sub}} = x_{\text{ref}} + \delta\)
(with \(\| \delta \|_\infty \le 0.08\))
such that:
Both flows are implemented in PyTorch and are fully differentiable.
The reference window is a 2D numpy array of shape
(1, 1, 64, 64) — a single-channel 64x64 image patch.
Understanding the Models
We loaded both .pt files using torch.load()
with weights_only=False and examined their
architectures:
Both are RealNVP-style normalizing flows composed of
multiple affine coupling layers with alternating checkerboard masking
patterns. Internally they use convolutional subnetworks with ActNorm
layers for stable training. The architectures are nearly identical —
both have the same number of coupling layers and similar parameter
counts, but with different learned weights.
The key operations for a coupling layer with mask \(m\):
where \(f = f_L \circ \cdots \circ
f_1\) and \(\pi\) is the base
Gaussian density.
The Jacobian determinant for each affine coupling layer is
simply:
\[\log |\det J_{f_k}| = \sum_i
s(x_1)_i\]
making it trivial to compute the total log-det-Jacobian — this is
just the sum across all coupling layers of the scale outputs.
The check script requires \(\log|\det
J_{f_1}|\) to be within a specific band. This constraint prevents
trivial solutions where the log-likelihood of model 1 is high purely
because of extreme volume distortion.
Checking the Reference
We loaded the reference window and evaluated both models:
The reference is already in-distribution for both
models (log-likelihoods around 925) and satisfies the Jacobian
constraint. The only problem: the margin is -1.90 — we
need it to be at least +30. So we need to find a tiny
perturbation that changes the relative log-likelihood by about 32 nats
while keeping everything else stable.
The Attack:
Gradient-Based Optimization
Since both flows are differentiable, we can compute the gradient of
the margin with respect to the input:
\[\nabla_\delta (\log q_1 - \log
q_0)\]
The idea is simple: take a step in the direction that maximally
increases \(\log q_1\) relative to
\(\log q_0\). But there's a critical
twist — the gradient magnitudes near the reference point are
enormous.
1 2 3 4 5 6
x = torch.tensor(x_ref, requires_grad=True, dtype=torch.float32) log_p0 = flow_0.log_prob(x) log_p1 = flow_1.log_prob(x) margin = log_p1 - log_p0 margin.backward() grad = x.grad.detach().clone()
The gradient \(L_2\) norm was around
700,000. This means even a tiny step in gradient
direction yields massive changes in the margin. This makes sense: near
the reference, the two models have slightly different density
landscapes, and because of the exponential nature of the flow mapping,
small changes in input space can produce large changes in
log-likelihood.
We normalize the gradient and perform a line search
over step sizes:
clipping to ensure \(\|\delta\|_\infty \le
0.08\).
The step size needed was on the order of \(\alpha \approx 3.1775 \times
10^{-6}\) — extremely tiny. Any larger and \(\log q_1\) would collapse below the
required threshold due to the sheer gradient magnitude.
Line Search Results
We scanned \(\alpha\) from \(3.0 \times 10^{-6}\) to \(3.6 \times 10^{-6}\):
The Jacobian determinant stayed constant at 1516.90 throughout
(within the required band) — the perturbation is too small to
meaningfully change the volume distortion. The log-likelihood of model 0
actually decreases slightly as we move away from the
reference, while model 1's log-likelihood increases,
creating the desired margin.
The winning hyperparameter: \(\alpha = 3.40 \times 10^{-6}\)
giving:
The final perturbation was saved, base64-encoded, and submitted via
the check script:
1 2 3 4 5 6 7 8
# Compute the perturbation delta = alpha * grad_sign # normalized gradient direction x_sub = x_ref + delta x_sub = np.clip(x_sub, 0.0, 1.0) # valid pixel range
# Encode and submit import base64, numpy as np payload = base64.b64encode(x_sub.astype(np.float32).tobytes()).decode()
The remote service confirmed the solution, returning the flag:
1
UMDCTF{****************************************}
Key Insight
The challenge name and flag hint at the core idea:
Gram-Schmidt orthogonalization and the geometry of
adversarial examples in the likelihood space of normalizing flows.
Two models trained on similar data define slightly different density
landscapes. In a tiny neighborhood around a point in-distribution, their
log-likelihood gradients can point in very different directions (they
are not perfectly aligned in function space). By following the
difference-of-gradients direction, we exploit the disagreement
manifold — the region where model 1 assigns higher likelihood
than model 0.
The extreme sensitivity (gradient norms ~700k) arises because:
Normalizing flows chain many bijective transforms, each amplifying
small input changes
The Jacobian of the flow near the data manifold can have large
singular values
Even though the models are similar, their gradient directions differ
enough that a tiny step (\(3.4 \times 10^{-6}\)) suffices to swing the
margin by 32 nats
This is a pure white-box adversarial attack on the
log-likelihood ratio, analogous to Fast Gradient Sign Method (FGSM) but
in log-probability space with a directional objective.
Reflection
This challenge was a beautiful blend of generative
modeling and adversarial machine learning. It
rewarded understanding:
How normalizing flows compute exact likelihoods
That differentiability enables gradient-based input
optimization
That similar models can be teased apart by their gradient
disagreement
That the scale of gradients matters — enormous gradients require
nanometer-scale steps
That Jacobian constraints prevent trivial large-volume-distortion
solutions
The "Gram-Schmidt" reference in the flag suggests the intended
solution may have involved orthogonalizing the gradients of the two
models, but simple directional gradient ascent on the margin works just
as well when the step size is chosen carefully.
flow was a fascinating adversarial machine learning
challenge. We were given a pre-trained CNN classifier, a RealNVP-style
normalizing flow model, and a reference time-series window
x_ref of shape (5, 64) — think of it as 5
sensor channels with 64 timesteps each.
defpurify(x, flow, n_steps=3, alpha=0.55): for _ inrange(n_steps): z, _ = flow.forward(x) x = flow.inverse(alpha * z) return x
After 3 steps,
x_purified = flow.inverse(0.55^3 * z_submit). The
classifier sees this shrunk latent version, while the flow likelihood
check is performed on the original submitted sample.
This mismatch is the key vulnerability. The defense checks likelihood
before purification but classifies after, creating a
differentiable pipeline that's ripe for gradient-based exploitation.
Attack Strategy:
Optimize in Latent Space
The core insight is that we can backpropagate through the entire
pipeline — flow encoder, latent manipulation, flow decoder, and
classifier — to find an input that simultaneously satisfies all
constraints.
Here's the approach:
Encode the reference window into latent space:
z_ref = flow.encoder(x_ref)
Optimize z with gradient descent to maximize class 1
logits under the purified version, while penalizing:
L-infinity distance from x_ref in the original
(submitted) space
Run this with Adam for ~3000 steps, and we converge to a solution
that satisfies all constraints.
Results
Our final submission achieved:
Metric
Value
Target
L-infinity
0.049
<= 0.05
Log-probability
1062
>= threshold
P(class=1)
0.825
>= 0.80
A lovely nod to the seminal work on adversarial examples (Explaining and Harnessing
Adversarial Examples, Goodfellow et al.) — though the Carlini &
Wagner attack (C&W) specifically inspires the optimization-based
approach used here.
Key Takeaways
Never trust a mismatch between defense checks and
classification inputs. If you check the submitted sample but
classify a purified version, an adversary can exploit the gradient path
through both.
Normalizing flows are differentiable end-to-end,
making them a double-edged sword: they can purify, but they can also be
used to craft adversarial inputs when the full pipeline is
exposed.
Optimization-based attacks (à la C&W) are more
powerful than fast gradient methods when you have access to the full
model. 3000 steps of Adam beats one step of FGSM every time.
A gambling game where the house doesn't actually have an edge — if
you can see through the deck. Predictable RNG + leaked server secrets =
25 consecutive wins and a flag.
Challenge: rainbet.challs.umdctf.io — a betting site
requiring 25 max wins in a row. Two game modes: Mines and Chicken.
Given files:rainbet.py (reference
wrapper) and rainbet_gen.wasm (the leaked RNG backend).
1. Reconnaissance
The site greets you with a gambling UI. Create an account, get a
session, and you can play Mines or Chicken. The goal is etched on the
front page: win 25 rounds in a row at maximum
payout.
The server exposes two critical API endpoints:
1
GET /api/sessioninfo
{: file='endpoint'}
Returns session_id (a hex string) and
hmac_secret (also hex). Both are per-session and stable for
the lifetime of the session.
The websocket endpoint sends a welcome message upon connection that
includes the current round_idx.
1
GET /api/socket
{: file='endpoint'}
WebSocket handshake. The server's first message contains
round_idx in the JSON payload.
Two delivered files point at the architecture:
rainbet.py — a thin Python wrapper
showing how the server loads and calls the WASM module. It imports
rainbet_gen.wasm, calls
generate(session_id, round_idx) which returns the game
state, and verifies HMAC-signed actions.
rainbet_gen.wasm — the actual game
generation logic. It takes a session_id (16 bytes) and
round_idx (u32), seeds an internal RNG with them, and
deterministically produces the full game board.
2. The Bug
The core vulnerability is a complete failure of information
hiding:
What should be secret
Where it leaked
The RNG algorithm
rainbet_gen.wasm was shipped to the browser
The RNG seed material
session_id exposed at
/api/sessioninfo
The HMAC signing key
hmac_secret exposed at
/api/sessioninfo
The current round index
round_idx in the WebSocket hello
Because generate(session_id, round_idx) is a pure
function with no side effects, anyone who calls the same
function with the same arguments gets the exact same game. The
server calls it after you connect to generate your current game; you can
call it locally with the same session_id and
round_idx to see the game too.
Once you know the game board, making the winning move is trivial. The
only remaining hurdle is forging a valid HMAC signature so the server
accepts your action — but you already have hmac_secret.
3. Solving Approach
3.1 Local WASM invocation
We use Node.js to instantiate the leaked WASM module and call
generate directly:
The exported generate function takes
(sessionIdPtr, roundIdx) where sessionIdPtr is
a pointer into WASM linear memory containing the 16-byte session ID and
roundIdx is a 32-bit unsigned integer.
After calling generate, the WASM writes the game state
into memory at a predictable offset (discoverable by reading the Python
wrapper or tracing the WASM exports). We read it back and parse the
game.
3.2 Game parsing
Two game types exist:
Mines — the board is a grid with hidden mines.
generate returns a list of mine positions. We reveal every
tile that isn't a mine. The HMAC payload format is:
1
mines:<streak>:<size>:<num_mines>:<tiles>
where <tiles> is a comma-separated list of tile
indices.
Chicken — the player crosses a bridge step by step;
some steps are safe, others collapse. generate returns the
safe step count. We cash out at that exact safe step (maximum safe
position). Payload format:
1
chicken:<streak>:<steps>:<crossed>
3.3 HMAC forgery
The hmac_secret is a hex string. The server uses
HMAC-SHA256 to sign action payloads: