Summary
The challenge ships a single file, activating_neurons.py, defining a small
nn.Module called BrunsvigerNet. The model has two linear layers, but
forward() only runs the input through the first layer before returning -
the second layer, which holds all the pre-trained weights, is never called.
Because the dummy input is a zero vector, the first layer's untrained random
weight matrix contributes nothing, leaving a fixed, deterministic bias
vector. Restoring the missing call to the second layer feeds that vector
through the pre-trained weights and produces 70 output values that decode
directly to ASCII, spelling out the flag.
1. Recon - reading the model definition
cat activating_neurons.py
Key structure:
class BrunsvigerNet(nn.Module):
def __init__(self):
super().__init__()
self.input_layer = nn.Linear(4, 4)
self.hidden_layer = nn.Linear(4, 70)
# Only the bias of input_layer and the weight+bias of
# hidden_layer are explicitly set. input_layer.weight is
# left at its random default init.
self.input_layer.bias = nn.Parameter(torch.tensor([-0.797, -0.047, 0.527, 1.965]))
self.hidden_layer.weight = nn.Parameter(torch.tensor([...70 rows of 4 floats...]))
self.hidden_layer.bias = nn.Parameter(torch.tensor([...70 floats, mostly 35-125...]))
def forward(self, x):
x = self.input_layer(x)
# Something seems to be missing?
return x
Two details stand out immediately:
-
hidden_layerhas 70 outputs and a bias vector whose values (roughly 35-125) sit squarely in the printable ASCII range.input_layer's bias only has 4 values and is nowhere near that range. This is a strong hint that the flag lives inhidden_layer's output, notinput_layer's. - The comment
# Something seems to be missing?sits exactly where a call tohidden_layershould be.forward()never useshidden_layerat all, so as written, the model is "completely passive and linear" - it just applies one affine transform and stops.
2. Working out why the input doesn't matter
dummy_input = torch.zeros(4)
input_layer.weight was never explicitly set, so PyTorch left it at its
random default initialization - normally that would make the output
non-deterministic. But the driver script always calls the model with a zero
vector:
input_layer(x) = input_layer.weight @ x + input_layer.bias
= input_layer.weight @ 0 + input_layer.bias
= input_layer.bias
The random weight matrix gets multiplied by zero and drops out entirely.
Whatever input_layer(dummy_input) produces is just the fixed bias vector
[-0.797, -0.047, 0.527, 1.965], regardless of the random init. This makes
the rest of the computation fully deterministic even though one layer's
weights were never pinned down.
3. Patching the model
The fix is a one-line addition to forward(), restoring the call to
hidden_layer:
def forward(self, x):
x = self.input_layer(x)
x = self.hidden_layer(x) # <-- restored
return x
With this in place, the fixed 4-value output of input_layer is projected
through hidden_layer's pre-trained 70x4 weight matrix and offset by its
bias, giving 70 deterministic float outputs.
4. Decoding the output
The existing decode logic in the script does the rest - round each output
to the nearest integer and treat it as a Unicode code point:
with torch.no_grad():
output = model(dummy_input)
flag = "".join([chr(int(round(val.item()))) for val in output])
print(f"[+] Output: {flag}")
$ python3 activating_neurons_fixed.py
[+] Output: brunner{ml_c4n_b3_fun_th3_m05t_1mp0rt4nt_1ngr3d13nt_15_l0v3_4nd_5ug4r}
Full patched script
import torch
import torch.nn as nn
import torch.nn.functional as F
class BrunsvigerNet(nn.Module):
def __init__(self):
super().__init__()
self.input_layer = nn.Linear(4, 4)
self.hidden_layer = nn.Linear(4, 70)
self.input_layer.bias = nn.Parameter(torch.tensor([-0.797, -0.047, 0.527, 1.965]))
self.hidden_layer.weight = nn.Parameter(torch.tensor([[-4.39, -2.423, -1.867, -1.904], [1.353, -0.14, -1.779, -4.539], [2.281, -2.412, -1.322, 0.744], [-3.722, -1.752, -0.022, -3.183], [-2.911, 0.174, 0.565, -4.052], [4.878, 1.219, -2.58, -3.097], [-0.069, -1.732, -4.861, -4.858], [-0.969, 3.21, 2.105, 0.866], [0.417, 4.577, 1.644, 0.429], [-1.569, -4.538, -2.854, 1.111], [4.325, 2.35, 2.515, 0.64], [1.888, -3.13, -1.799, 2.074], [-2.475, -2.086, -1.14, -3.35], [2.057, -3.601, -4.482, -0.609], [1.932, -1.995, 1.549, 4.417], [-1.827, 4.91, -3.891, -0.415], [-0.118, -3.945, 0.162, -4.841], [-2.268, -2.489, 1.48, -1.604], [4.132, 0.383, 0.343, 3.892], [3.035, 4.214, -2.314, -2.049], [4.254, 0.817, 1.833, -0.558], [4.541, 2.546, 2.009, -2.498], [-1.792, -0.393, -3.151, 0.826], [1.283, 0.606, -1.593, -0.719], [-4.634, -0.794, 2.015, -0.929], [3.41, -0.978, -3.722, 0.468], [-1.268, 3.495, -3.803, 3.39], [-2.066, 0.186, -1.032, -0.416], [4.403, 2.929, 4.3, -3.7], [-2.59, 3.617, -0.043, -0.883], [1.154, -2.86, -0.17, 3.609], [-3.072, 1.273, -0.161, 2.205], [0.379, -1.633, 3.722, -3.587], [2.007, -1.652, -1.695, 0.465], [-4.604, -3.688, 4.422, 3.472], [0.197, -0.993, -4.595, 4.734], [-3.434, -4.237, -4.243, 3.235], [3.894, -3.769, 1.402, 3.643], [1.968, -0.144, 1.857, 0.7], [1.691, -2.967, -4.05, -2.571], [4.895, -3.5, -0.629, -0.15], [-2.357, -1.781, -4.421, -0.665], [-2.088, 0.465, 1.621, -4.975], [2.726, 4.976, -1.18, -4.023], [4.693, -2.901, -2.351, -1.66], [-0.295, -4.371, 4.282, 3.575], [-3.862, -2.341, 2.797, 0.406], [3.652, -1.441, 0.22, 0.786], [-4.659, -0.073, -0.502, -3.23], [-4.216, -1.045, -1.222, -4.161], [0.265, -4.894, 0.172, -1.409], [-2.073, -3.28, 0.749, -3.344], [-2.543, 2.4, -3.346, 1.413], [4.685, 4.291, -4.936, -0.571], [2.029, 3.904, 4.232, -1.808], [-4.278, -3.547, 0.927, -1.0], [-4.274, 2.469, 4.981, -1.513], [-2.374, 4.603, -2.192, -3.001], [-1.222, -2.382, 3.372, -3.695], [0.924, -3.743, -1.743, 1.862], [-2.303, -3.936, 2.809, 0.191], [-0.839, -3.08, -1.847, 1.234], [-2.654, 1.084, -0.325, 1.65], [0.39, 4.978, 3.792, -0.999], [4.583, 4.91, 3.66, -2.061], [-0.436, 0.846, -1.334, -1.999], [-1.104, -0.372, -3.081, 1.422], [-2.045, 3.593, -2.272, 3.4], [1.881, 1.313, -3.93, -3.481], [3.875, -4.237, 4.047, 0.312]]))
self.hidden_layer.bias = nn.Parameter(torch.tensor([99.112558, 124.928429, 117.939327, 113.217411, 115.352536, 112.390324, 125.97132, 119.567552, 107.838095, 105.857164, 95.97447, 97.230289, 57.112913, 115.028881, 86.950311, 99.640683, 60.14773, 95.447321, 97.482664, 124.862716, 113.559316, 102.588666, 114.590792, 107.303379, 48.032964, 98.713678, 103.4965, 47.723444, 61.651254, 115.863525, 88.78322299999999, 42.363469, 114.312273, 113.501475, 35.004402, 107.229593, 108.943249, 47.029026, 109.207589, 124.394643, 99.363048, 50.674356, 117.279327, 113.933549, 122.104851, 41.277958999999996, 94.54015, 50.182487, 53.89485, 115.411092, 118.659228, 99.369896, 46.072826, 60.658909, 98.123057, 105.900196, 45.057723, 123.376412, 55.397743, 92.820238, 48.123859, 107.735116, 94.864735, 95.509447, 59.004466, 121.323323, 100.932085, 45.05535, 124.472143, 125.143387], dtype=torch.float32))
def forward(self, x):
x = self.input_layer(x)
# Fixed: the missing hidden layer activation
x = self.hidden_layer(x)
return x
model = BrunsvigerNet()
model.eval()
dummy_input = torch.zeros(4)
with torch.no_grad():
output = model(dummy_input)
flag = "".join([chr(int(round(val.item()))) for val in output])
print(f"[+] Output: {flag}")
Key Vulnerabilities / Design Weaknesses
| # | Weakness | Impact |
|---|---|---|
| 1 |
forward() skips the layer holding the actual secret |
Model output is meaningless until the missing layer call is restored |
| 2 |
input_layer.weight left at random init |
Normally non-deterministic, but neutralized by the always-zero dummy input, making the "secret" fully recoverable with static analysis alone |
| 3 | Secret encoded as raw ASCII via chr(round(x))
|
Once the correct layer is invoked, no further cryptography protects the output |
Attack Chain
+----------------------------+
| Read model source |
| (weights/biases in plain |
| text, forward() incomplete) |
+--------------+---------------+
|
v
+----------------------------+
| Notice hidden_layer.bias |
| values fall in ASCII range |
| (35-125) -> secret lives |
| in hidden_layer output |
+--------------+---------------+
|
v
+----------------------------+
| Confirm dummy_input=zeros |
| nulls out input_layer's |
| random weight, leaving a |
| fixed bias vector |
+--------------+---------------+
|
v
+----------------------------+
| Patch forward(): |
| add x = hidden_layer(x) |
+--------------+---------------+
|
v
+----------------------------+
| Run model, round outputs, |
| chr() each value |
+--------------+---------------+
|
v
+----------------------------+
| Flag recovered |
+----------------------------+
Mitigations (if this were a real system rather than a CTF)
- Never ship pre-trained weights or biases in plaintext source alongside the inference code; store them in a separate artifact with access controls.
- Don't rely on an incomplete
forward()pass as an obfuscation mechanism - it's trivially fixed by anyone who can read the class definition. - If a value truly needs to stay secret, encrypt or hash it rather than encoding it as reversible ASCII code points in model parameters.
- Avoid always calling inference with a fixed, predictable input (e.g. an all-zero vector) if any part of the security model depends on the input actually varying.
Files
-
activating_neurons.py- original challenge file -
activating_neurons_fixed.py- patched version with the restoredhidden_layercall inforward()
Top comments (0)