Back to Qu1ck5h0t's Writeups

This challenge is certainly the first of its kind I’ve seen, and by that I mean it’s a 1:1 copy of a PicoCTF chall that literally came out less than half a year ago. Still a great crypto chall, solved regardless.

Context: this screenshot is from gaslightCTF, which fully bans AI. Nothing more to say here.

Before I even started looking at the chall, I intuitively guessed that it was probably something to do with the removal of the S-box.

Before I even fully read the chall function (which was just a regular AES-128 implementation), I found that I was right.

Indeed, they removed the SubBytes (S-box) function from the cipher. But just because a nonlinear step was removed from AES doesn’t mean you can just instantly decrypt any secret right? Nope, not in a SPN. Let me explain why, this time WITHOUT AI assistance since it was a noslop CTF. Yep I know the theory off the top of my head :D

First and foremost, the fundamental definition of linear encryption function is F() such that

F(a ⊕ b) = F(a) ⊕ F(b)

Noting whether a function is linear is significant because security for most block ciphers ultimately depend on whether or not the function can be expressed as

C = P ⊕ K

For C being the ciphertext, P being the plaintext and K being some sort of constant keysum.

This expression is instantly “breakable” purely because K can be recovered from a single known ciphertext/plaintext pair. We try to use nonlinear functions within our ciphers because a nonlinear round function R() cannot be elementarily decomposed as such:

R(R(P ⊕  K) ⊕ K) ≠ R(R(P)) ⊕ R(R(K)) ⊕ R(K)

Hence disallowing a trivial cipher break through expressing the entire cipher as a XOR sum, then treating it as the case above. However, since SubBytes is the only nonlinear step, AES can now be expressed as a recursive function identical to the one above where P (input) is effectively the C (output) from the previous round. Hence, the cipher is vulnerable to a single known plaintext attack. When I looked further at the code, I noticed that there exists a known plaintext in the source code corresponding to a ciphertext in the output file

Hence, with the cipher equation in terms of of round function R() and subkey Ki, for ∑R(K) being the XOR sum R(K) ⊕ R(R(K)) ⊕ R(R(R(K)))... as such:

C = R(R(R…(P ⊕ K1) ⊕ K2)… ⊕ Kn) = R(R(R..(P))...) ⊕ ∑R(K)

By extension (since AES steps are reversible):

R-1(R-1(R-1…(C)))...) = P ⊕ R-1(R-1(R-1…(∑R(K)))...)

Through the above equivalence (I’ll call this lemma 1 since we’ll be referring back to it later), we can attain the corresponding plaintext XORed against R-1(R-1(R-1…(∑R(K)))...), which I will call ∑R(K)inv, from any ciphertext. However, that alone is insufficient to isolate the plaintext. In order to do that, we need to cancel out ∑R(K)inv with XOR’s self inverse property such that:

P ⊕ ∑R(K)inv ⊕ ∑R(K)inv = P ⊕ 0 = P

We can achieve this by mixing two ciphertexts. Since R-1() is also linear, the below expression holds true:

R-1(R-1(R-1…(C1 ⊕ C2)))...) = R-1(R-1(R-1…(C1)))...) ⊕ R-1(R-1(R-1…(C2)))...)

Through lemma 1, the expression can also be expressed as below:

 P1 ⊕ ∑R(K)inv ⊕ P2 ⊕ ∑R(K)inv

Which we know is just P1 ⊕ P2, as both ciphertexts use the same K. Hence, if we let P1/C1 be the known plaintext/ciphertext pair, and C2 be the ciphertext of the flag, we can recover the flag by using the above method and then XORing against the known P1.

Now that I had the idea, I needed to start writing the code (yes I did think of all of that before I even started coding, I know I am very smart). While I could implement inverse mixcolumn and shiftrow functions for AES 128, I was too lazy (and also time was an important factor). So I stole invmixcolumn() and invshiftrow() from Vincent-G-Van on GitHub (most spaghetti code I’ve ever seen, don’t even bother reading it).

Since I already explained all the maths above, the code below should be pretty self explanatory:

def invR(x):
  x = invmixcolumn(BitVector(hexstring=x))
  x = invshiftrow(x)
 
return x

p1 =
"94ae785acdb0d7c919f4893697659c8c"
 pt = "696e636f6d70726568656e7369626c65"
 ct = "58f86ce660590bb05495c0dcd2d4d438"

 mix = int(p1, 16) ^ int(ct, 16)
mix =
f"{mix:032x}"
 mix = invshiftrow(mix)
for i in range(1, 10):
  mix = invR(mix)
flag =
int(pt, 16) ^ int(mix, 16)
print(hex(flag))

Running the code gives a hex string that when decided, reveals the flag.

Flag: gaslightCTF{newj34ns-nv-d!es}


Fun fact: as great as this challenge is, it is not original by any means. One of my teammates found the EXACT same challenge from PicoCTF 2026. They didn’t even bother changing the function names or even COMMENTS.

I suppose this would be the ACTUAL challenge then. It’s still nice I got to solve this manually since my teammates actually solved this one last year, and I didn’t get to join in on the fun until now! Also confronted the author about it lol