
This challenge presented a classic example of an insecure cryptographic mode of operation: AES-ECB. Despite an obfuscation attempt, the core vulnerability allowed for a straightforward byte-at-a-time decryption attack.

The provided script revealed the critical information:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Random import get_random_bytes
import random
# KEY INSIGHT #1: key not bruteforcable, placehodler_flag and ECB MODE!!!
N = 16
key = get_random_bytes(N)
flag = 'NICC{placeholder_flag}'
cipher = AES.new(key, AES.MODE_ECB)
def e1(p):
p = bytes.fromhex(p)
# KEY INSIGHT #2: Our input 'p' is directly concatenated with the flag
one = pad(p + flag.encode(), 16)
try:
enc = cipher.encrypt(one)
except:
return "Error"
enc = enc.hex()
# KEY INSIGHT #3: The "Distraction"
two = random.randint(0, 15)
txt = ""
for i in enc:
c = (int(i, 16) + two) % 16 # Adds a random 'two' to each hex digit
c = f"{c:x}"
txt += c
return {"C": txt}
From this, we identify the core components:
p.p (as bytes) with the secret flag and applies PKCS#7 padding to make the total length a multiple of 16 bytes.AES in ECB mode is used with a secret key.two), which is generated once per request.The Electronic Codebook (ECB) mode is notoriously insecure for data larger than a single block because it encrypts each 16-byte block independently using the same key. The fundamental principle of ECB is: If two plaintext blocks are identical, their corresponding ciphertext blocks will also be identical.
The two variable adds a random offset to each hex digit of the ciphertext. For example, if two = 5, an original ciphertext hex digit 'a' (10) becomes 'f' (15).
However, this "distraction" is irrelevant to our attack. The two value is constant for the entire encryption request. If Ciphertext_Block_A == Ciphertext_Block_B before the shift, they will still be equal after the shift, as the same two value is applied to both. This means we can still detect identical blocks within a single server response.

Our goal is to reveal the flag byte by byte. We exploit the ECB property by forcing the server to encrypt two identical plaintext blocks within a single request:
flag_byte we are trying to discover.If Our Guess Block == Target Block in plaintext, then their encrypted forms will also be identical. By iterating through all possible byte values for our guess_byte, we can find the one that produces a match.
Before attacking, we can determine the flag length. We send varying lengths of known padding (e.g., '00', '0000', etc.) and observe when the total ciphertext length (in hex characters) increases from 32 (1 block) to 64 (2 blocks). The length of our input p that causes this jump, subtracted from 16, reveals the flag's length.
p = "": len(flag) + padding_needed = 1 block (32 hex chars)p = "000000" (6 bytes): 6 + len(flag) = 16 bytes. This triggers a full 16-byte padding block.
6 + 10 = 16 (first block)16 (full padding block)flag_length = 16 - 6 = 10 bytes. (For our example v3ryS3cret).Let's illustrate with an example flag: v3ryS3cret (10 bytes).
2.1 Discovering flag[0] (e.g., 'v')
At this stage, known_flag = b''.
pad_len Calculation: We need 15 - (len(known_flag) % 16) = 15 bytes of 'A' as alignment.p_guess_payload = (b'A' * 15) + known_flag + guess_byte (e.g., b'AAAAAAAAAAAAAAAv')p_target_align = (b'A' * 15)Full Payload (sent to server): p_guess_payload + p_target_align (e.g., b'AAAAAAAAAAAAAAAvAAAAAAAAAAAAAAA')[AAAAAAAAAAAAAAAv] (Block 0: Our Guess Block from p_guess_payload)[AAAAAAAAAAAAAAAv] (Block 1: Our Target Block from p_target_align + flag[0])[3ryS3cret#####] (Block 2: Remaining flag + padding)C(Block 0) with C(Block 1). If guess_byte = 'v', they match.
Caption: Attack Logic for flag[0]. We construct a payload such that the black text is our controlled input and the red text is the unknown flag and padding added by the server. A match in ciphertext blocks reveals the correct byte.
This process continues for flag[1], flag[2], and so on.
In this case enc(1st block) == enc(2nd block) so v is the first char of the "flag"
2.2 The little twist! The "Block Shift" Challenge (Practical e.g., Discovering flag[16] for the real flag)
This is where the block alignment gets tricky. Let's imagine we've found known_flag = b'NICC{https://www' (16 bytes, the length of a full block).
pad_len Calculation: 15 - (len(known_flag) % 16) = 15 - (16 % 16) = 15 bytes of 'A'.p_guess_payload = (b'A' * 15) + known_flag + guess_byte
b'AAAAAAAAAAAAAAANICC{https://www.' (This is now 32 bytes long!)p_target_align = (b'A' * 15)Full Payload: p_guess_payload + p_target_align
b'AAAAAAAAAAAAAAANICC{https://www.AAAAAAAAAAAAAAA' (47 bytes)[AAAAAAAAAAAAAAAN] (Block 0: Part of p_guess_payload)[ICC{https://www.] (Block 1: Our Guess Block from p_guess_payload)[AAAAAAAAAAAAAAAN] (Block 2: From p_target_align + flag[0])[ICC{https://www.] (Block 3: Our Target Block from flag[1] to flag[16])[youtube.com/wat] (Block 4: Remaining flag)Block 0 and Block 1, our script must dynamically determine that Block 1 and Block 3 are the correct ones to compare.
guess_block_index would evaluate to 1.target_block_index would evaluate to 3.The final script automates this byte-at-a-time process. It dynamically calculates pad_len and the guess_block_index and target_block_index to ensure it's always comparing the correct blocks, regardless of known_flag's length.
#!/usr/bin/env python3
from pwn import *
import string
HOST = '1337.address'
PORT = 1337
r = remote(HOST, PORT)
def get_ciphertext(payload_bytes):
r.recvuntil(b'Enter p (hexadecimal): ')
r.sendline(payload_bytes.hex().encode())
response_line = r.recvline().strip().decode()
return response_line
known_flag = b''
# Search space includes printable characters and then all other bytes
printable_bytes = b'}' + bytes(string.printable, 'utf-8').replace(b'}', b'')
all_bytes = bytes(range(256))
search_space = printable_bytes + bytes(b for b in all_bytes if b not in printable_bytes)
log.info("Starting byte-at-a-time attack...")
while True: # for safety we could've done something like len(known_flag) < 50 supposing that flag wouldn't be bigger than 50chars
current_flag_len = len(known_flag)
# Calculate padding needed to align the unknown byte at the end of a block
pad_len = 15 - (current_flag_len % 16)
p_align = b'A' * pad_len
found_byte = False
for b in search_space:
guess_byte = bytes([b])
# p_guess_payload: Our controllable block(s) ending with the current guess
p_guess_payload = p_align + known_flag + guess_byte
# p_target_align: Padding to align the actual flag byte into a comparable block
p_target_align = p_align
# The full payload sent to the server in a single request
payload = p_guess_payload + p_target_align
C_full = get_ciphertext(payload)
# Dynamically determine the indices of the guess and target blocks
guess_block_index = (len(p_guess_payload) - 1) // 16
# target_byte_index is the absolute position of the flag byte we're trying to find
target_byte_index = len(p_guess_payload) + pad_len + current_flag_len
target_block_index = target_byte_index // 16
# Extract the ciphertext blocks (each block is 32 hex characters)
guess_start = guess_block_index * 32
guess_end = (guess_block_index + 1) * 32
c_block_guess = C_full[guess_start:guess_end]
target_start = target_block_index * 32
target_end = (target_block_index + 1) * 32
c_block_target = C_full[target_start:target_end]
# Compare the extracted ciphertext blocks
if c_block_guess == c_block_target:
known_flag += guess_byte
log.info(f"Found: {known_flag.decode()}")
found_byte = True
# If we hit the closing brace, we likely have the full flag
if guess_byte == b'}':
log.success(f"We got it boysss: {known_flag.decode()}")
break
break # Found the byte, move to the next byte in the flag
# If the closing brace was found, or no byte was found (end of flag/error)
if guess_byte == b'}' or not found_byte:
break
r.close()
By running the script, we successfully extracted the flag:
NICC{https://www.youtube.com/watch?v=NeI4jwXLX1Y} A beautiful breakcore drum beat ahahah