SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/GASLIGHTCTF-2026-NEWJEANS-IS-FIVE-WRITEUP
← BACK TO ARCHIVE
EVENT: gaslightCTF 2026CATEGORY: CryptographyPOINTS: 500 PTS

GaslightCTF 2026 - NewJeans Is Five Writeup

AUTHORED BY:@bealthguy8/15/2026

Writeup – NewJeans is Five

Challenge Overview

We are given:

  • chall.py – an implementation of AES‑128 but with a critical component missing:
    • SubBytes and SubWord are replaced by the identity function (they just return the input unchanged).
  • output.txt – two ciphertexts:
    • 94ae785acdb0d7c919f4893697659c8c → encryption of known plaintext "incomprehensible" (hex 696e636f6d70726568656e7369626c65)
    • 58f86ce660590bb05495c0dcd2d4d438 → encryption of the flag (unknown)

The description hints: “NewJeans is not NewJeans without any one of its members. What is AES without one of its components?”
The missing component is SubBytes (the S‑box).


Vulnerability

Without SubBytes, AES becomes a completely linear transformation over GF(2).
All remaining operations are:

  • ShiftRows – permutation
  • MixColumns – linear over GF(2⁸) (multiplication by fixed polynomial)
  • AddRoundKey – XOR with round key (affine, but the key part is constant)

Thus, for a fixed key, the encryption function is affine in the plaintext:

[ C = A(P) \oplus K_{\text{const}} ]

where (A) is the linear part (independent of the key) and (K_{\text{const}}) depends only on the key.

When we have two plaintext‑ciphertext pairs with the same key, the key‑dependent constant cancels out:

[ C_1 \oplus C_2 = A(P_1) \oplus A(P_2) = A(P_1 \oplus P_2) ]

Because (A) is linear.

So we can recover (P_1 \oplus P_2 = A^{-1}(C_1 \oplus C_2)), and then the unknown plaintext:

[ P_2 = P_1 \oplus A^{-1}(C_1 \oplus C_2) ]


Recovering the Linear Transformation (A)

From the encryption algorithm:

  1. Initial AddRoundKey – key cancels out when XORing, so it does not appear in the difference.
  2. For rounds 1 to 9:
    • SubBytes (identity) – no effect.
    • ShiftRows
    • MixColumns
  3. After the loop:
    • SubBytes (identity)
    • ShiftRows
    • AddRoundKey – again cancels.

So the linear map (A) that transforms the plaintext difference to the ciphertext difference is:

[ A = \text{ShiftRows} ;\circ; (\text{MixColumns} \circ \text{ShiftRows})^9 ]

We can implement (A) and its inverse directly.


Solver Script

The script below:

  • Defines GMul, ShiftRows, MixColumns and their inverses.
  • Computes (A^{-1}(C_1 \oplus C_2)).
  • Recovers the flag by XORing with the known plaintext.
from binascii import unhexlify, hexlify

def GMul(a, b):
    p = 0
    for _ in range(8):
        if b & 1:
            p ^= a
        a <<= 1
        if a & 0x100:
            a ^= 0x11b
        b >>= 1
    return p

def bytes_to_matrix(data):
    matrix = [[class="hljs-string">&#039;&#039;]*4 for _ in range(4)]
    for i in range(16):
        row = i % 4
        col = i // 4
        matrix[row][col] = fclass="hljs-string">"{data[i]:02x}"
    return matrix

def matrix_to_bytes(matrix):
    data = bytearray()
    for j in range(4):
        for i in range(4):
            data.append(int(matrix[i][j], 16))
    return bytes(data)

def ShiftRows(state):
    state[1][0], state[1][1], state[1][2], state[1][3] = state[1][1], state[1][2], state[1][3], state[1][0]
    state[2][0], state[2][1], state[2][2], state[2][3] = state[2][2], state[2][3], state[2][0], state[2][1]
    state[3][0], state[3][1], state[3][2], state[3][3] = state[3][3], state[3][0], state[3][1], state[3][2]
    return state

def InvShiftRows(state):
    # inverse of ShiftRows (shift right)
    state[1][0], state[1][1], state[1][2], state[1][3] = state[1][3], state[1][0], state[1][1], state[1][2]
    state[2][0], state[2][1], state[2][2], state[2][3] = state[2][2], state[2][3], state[2][0], state[2][1]  # same as left 2
    state[3][0], state[3][1], state[3][2], state[3][3] = state[3][1], state[3][2], state[3][3], state[3][0]
    return state

def MixColumns(state):
    temp = [[class="hljs-string">&#039;&#039;]*4 for _ in range(4)]
    for j in range(4):
        a0 = int(state[0][j], 16)
        a1 = int(state[1][j], 16)
        a2 = int(state[2][j], 16)
        a3 = int(state[3][j], 16)
        temp[0][j] = fclass="hljs-string">"{GMul(0x02, a0) ^ GMul(0x03, a1) ^ a2 ^ a3:02x}"
        temp[1][j] = fclass="hljs-string">"{a0 ^ GMul(0x02, a1) ^ GMul(0x03, a2) ^ a3:02x}"
        temp[2][j] = fclass="hljs-string">"{a0 ^ a1 ^ GMul(0x02, a2) ^ GMul(0x03, a3):02x}"
        temp[3][j] = fclass="hljs-string">"{GMul(0x03, a0) ^ a1 ^ a2 ^ GMul(0x02, a3):02x}"
    return temp

def InvMixColumns(state):
    temp = [[class="hljs-string">&#039;&#039;]*4 for _ in range(4)]
    for j in range(4):
        a0 = int(state[0][j], 16)
        a1 = int(state[1][j], 16)
        a2 = int(state[2][j], 16)
        a3 = int(state[3][j], 16)
        temp[0][j] = fclass="hljs-string">"{GMul(0x0e, a0) ^ GMul(0x0b, a1) ^ GMul(0x0d, a2) ^ GMul(0x09, a3):02x}"
        temp[1][j] = fclass="hljs-string">"{GMul(0x09, a0) ^ GMul(0x0e, a1) ^ GMul(0x0b, a2) ^ GMul(0x0d, a3):02x}"
        temp[2][j] = fclass="hljs-string">"{GMul(0x0d, a0) ^ GMul(0x09, a1) ^ GMul(0x0e, a2) ^ GMul(0x0b, a3):02x}"
        temp[3][j] = fclass="hljs-string">"{GMul(0x0b, a0) ^ GMul(0x0d, a1) ^ GMul(0x09, a2) ^ GMul(0x0e, a3):02x}"
    return temp

def forward_A(data):
    state = bytes_to_matrix(data)
    for _ in range(9):
        state = ShiftRows(state)
        state = MixColumns(state)
    state = ShiftRows(state)   # final ShiftRows
    return matrix_to_bytes(state)

def inverse_A(data):
    state = bytes_to_matrix(data)
    state = InvShiftRows(state)   # inverse of final ShiftRows
    for _ in range(9):
        state = InvMixColumns(state)
        state = InvShiftRows(state)
    return matrix_to_bytes(state)

# Given data
pt1_hex = class="hljs-string">"696e636f6d70726568656e7369626c65"
ct1_hex = class="hljs-string">"94ae785acdb0d7c919f4893697659c8c"
ct2_hex = class="hljs-string">"58f86ce660590bb05495c0dcd2d4d438"

pt1 = unhexlify(pt1_hex)
ct1 = unhexlify(ct1_hex)
ct2 = unhexlify(ct2_hex)

D = bytes(a ^ b for a, b in zip(ct1, ct2))
X = inverse_A(D)
flag_bytes = bytes(a ^ b for a, b in zip(pt1, X))

print(class="hljs-string">"Flag:", flag_bytes.decode())

Running the Script

Flag: newj34ns-nv-d!es

The recovered flag is newj34ns-nv-d!es.
In the required format gaslightCTF{...}, the final flag is:

gaslightCTF{newj34ns-nv-d!es}

Explanation of the Flag

The flag newj34ns-nv-d!es is a leetspeak version of “newjeans never dies” (with 3 for e, 4 for a, and ! for i).
The challenge name “NewJeans is five” refers to the five members of the K‑pop group NewJeans, and the vulnerability is the missing fifth AES component (SubBytes) – hence the flag plays on that.


Takeaways

  • Removing non‑linearity from a cipher makes it linear, which is catastrophically weak.
  • A known‑plaintext attack can recover the entire transformation and break the cipher without the key.
  • Always treat cryptographic primitives as a whole; changing any component can completely destroy security.