BlackHat MEA CTF 2025 Final (Riyadh, Saudi Arabia🇸🇦)

Time: 2025.12.02-04 (onsite)

My first time in Riyadh,KSA 🇸🇦 it's also my first time abroad, and surprisingly, it’s for a CTF. Life has a strange way of unfolding. Grateful to God . Only God and I know what it took to reach this moment. Literally packed my bags, thought I'm a world traveler now. Arrived in Riyadh, Saudi Arabia with team ===___=== (yeah thats really my team name haha..).

Three days of sleepless nights, lots of soft drinks, and questionable life choices later we somehow managed to get 12th place . Not too shabby for someone who barely knows how to use Google Maps. GG to my teammates who actually know what they're doing.But the sad part, After receiving my visa, I asked for support in my country,companies and from my university, but I was turned away. No one helped me not even with a single penny. Some of them literally laughed at me. I cried many times during this journey, carrying nothing but faith and determination. There were moments when I nearly gave up, but my friends gave me hope: Sayed, Sadif, Ajwad, and Adeeb. So firstly, I want to thank Uzbekistan 🇺🇿 and Turan Security Company. I will never forget you for standing by me when no one else did.

scoreboard

scoreboard2

day1

Que?

Note: This one was the only problem that i'm able to solve with my friend @chatgpt throughout this event , After returned into hotel I was looking at this challenge and at first it felt complex—lots of math, custom crypto, big primes. But once I slowed down, the mistake jumped out. Both secrets (k and r) are multiplied by the same small 24-bit mask. When the protocol computes C1 = k · r⁻¹ mod p, that mask cancels out completely. So the “128-bit secret” isn’t really 128 bits anymore. The bigger facepalm? Aaron encrypts the flag before the protocol even finishes. No authentication. No confirmation. Just straight AES-ECB with a deterministic key and a known flag format. That makes offline guessing trivial.

Tampering Detection System

AES-GCM

aes-gcm

Some code by me for a better understanding. (I prefer $E_0$ while it’s $J_0$ in the figure.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from sage.all import GF
from Crypto.Util.number import long_to_bytes, bytes_to_long
from Crypto.Cipher import AES


z0 = GF(2)["z0"].gen()
F, z = GF(2**128, name="z", modulus=z0**128 + z0**7 + z0**2 + z0 + 1).objgen()


def xor(a, b):
return bytes([ai^bi for ai, bi in zip(a, b)])

def pad128(pt):
return pt + b'\x00' * ((16 - len(pt))%16)

def len64(pt):
return long_to_bytes(len(pt)*8, 8)

def b2gf(b):
return F(list(bin(bytes_to_long(b))[2:].zfill(128)))

def gf2b(g):
return long_to_bytes(int(''.join(map(str, g.list())), 2), 16)


def TAG(key: bytes, aad: bytes, nonce: bytes, ct: bytes):
aes = AES.new(key=key, mode=AES.MODE_ECB)
E0 = aes.encrypt(nonce + long_to_bytes(1, 4))
H = aes.encrypt(long_to_bytes(0, 16))
C = pad128(aad) + pad128(ct) + len64(aad) + len64(ct)
TAG = 0
for i in range(0, len(C), 16):
TAG += b2gf(C[i:i+16])
TAG *= b2gf(H)
TAG += b2gf(E0)
return gf2b(TAG) # GHASH(H, C) + E0

def CT(key: bytes, nonce: bytes, pt: bytes):
aes = AES.new(key=key, mode=AES.MODE_ECB)
H = aes.encrypt(long_to_bytes(0, 16))
ctr = nonce + long_to_bytes(1, 4)
ct = b''
for i in range(0, len(pt), 16):
ctr = long_to_bytes(bytes_to_long(ctr) + 1, 16)
pti = pt[i: i+16]
cti = xor(pti, aes.encrypt(ctr)[:len(pti)])
ct += cti
return ct

GCM Routine

As always, we need to recover (H, E0) first. We have:

  • tag = GHASH(H, C) + E0
  • tag2 - tag1 = GHASH(H, C2) - GHASH(H, C1) = GHASH(H, xor(C2, C1))

Then we can find the roots of the polynomial on GF, which includes H. We don’t know (ct1, ct2) but for AES-GCM we have xor(ct1, ct2) = xor(pt1, pt2), and xor(C1, C2) = 0_128||xor(pt1, pt2)||0_64||0_64.

With H, we can solve E0 = tag_flag - GHASH(H, C_flag), thus enabling us to forge a valid tag for any ciphertext.

AAD Construction

In the second part we’re given a decryption oracle, requiring truncating length and aad. Our target is to recover ct and decrypt the flag.

One may notice that each ct corresponds to a unique aad, which can be solved from a linear equation on GF. Consider we have recovered ct[:i], we can bruteforce ct[:i+1] = ct[:i] + ch and find the real ch by checking the corresponding aad, thus recovering ct byte by byte.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
z0 = GF(2)["z0"].gen()
F, z = GF(2**128, name="z", modulus=z0**128 + z0**7 + z0**2 + z0 + 1).objgen()
x = F["x"].gen()

for ch in range(256):
aad = b"\x00"*16
ct = prefix + bytes([ch])
C = pad128(aad) + pad128(ct) + len64(aad) + len64(ct)
ftag = 0
for i in range(0, len(C), 16):
if i == 0:
Ci = x
else:
Ci = b2gf(C[i: i+16])
ftag += Ci
ftag *= b2gf(H)
ftag += b2gf(E0) + b2gf(tag2)
ans = ftag.univariate_polynomial().roots()
aad = gf2b(ans[0][0])

With a long enough ct, we can decrypt our flag now.

day2

doloRSitAmet

All about Sample Strategy

Clearly, 4 equations are not enough to solve the whole system, but we can ‘glue’ some variables by setting certain seeds. For instance we can fix 1,2 showing up together, which leads to a new variable.

At first I tried to split them into 3 parts like [(0), (1,2,3), (4)].

1
2
3
4
Seed 15901 gives [0, 1, 2, 3, 4]
Seed 22733 gives [4, 1, 2, 3, 0]
Seed 15054 gives [1, 2, 3, 0, 4]
Seed 17502 gives [1, 2, 3, 4, 0]

With 4 equations we can obtain a linear equation of the form $a \cdot x + b \cdot y + c = 0 \pmod{n}$. It’s indeed solvable with LLL, but we need some (too much) luck to get 2 small variables. This costed me a lot of time.

Then I realized if we glue 4 variables together, there’s only 2 variables left in 2 equations, and we can obtain a univariate polynomial. Just do it twice:

1
2
3
4
Seed 15901 gives [0, 1, 2, 3, 4]
Seed 17502 gives [1, 2, 3, 4, 0]
Seed 4145 gives [0, 2, 3, 4, 5]
Seed 15589 gives [2, 3, 4, 5, 0]

With 2 polynomials, we can solve the first variable by gcd. This gives us another linear equation with 2 variables (the first sentence and the flag sentence) but much smaller (there’s only 32 bytes unknown in the flag sentence), so we just LLL and get the flag.

limeade

A juicy challenge, for real.

Structure Analysis

To put it briefly, Lime includes permutation (Roll) and substitution (Squeeze) funcion. And Jucier essentially constructs a 2-round SPN cipher with each round using the same key:

  1. permuted (lime[0]) key addition
  2. 16 times substitution (lime[1]) and permutation (lime[2])
  3. permuted (lime[3]) key addition
  4. 16 times substitution (lime[4]) and permutation (lime[5])
  5. permuted (lime[6]) key addition

Boomerang Style Attack

We are provided Encryption/Decryption Oracle with additional fault injection on the key. But first let’s think about what we can do with xor fault injection.

An interesting observation is that $P \oplus K = (P \oplus 1) \oplus (K \oplus 1)$, so if we inject 1 bit fault on both the key and the plaintext at the same position, it shows no difference after the first round of encryption. But with the second key addition, the fault will be injected and the final ciphertext goes completely different.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Encryption
↓ P0, K → P0+perm[0](1), K+1

: P0 → P0+perm[0](1)
↓perm[0] key add
: P0+perm[0](K) → P0+perm[0](1)+perm[0](K+1) = P0+perm[0](K)
↓16 sub[1]-perm[2]
: P1 → P1
↓perm[3] key add
: P1+perm[3](K) → P1+perm[3](K+1)
↓16 sub[4]-perm[5]
: P2 → P2'
↓perm[6] key add
: P2+perm[6](K) → P2'+perm[6](K+1)

= P2+perm[6](K) → P2'+perm[6](K+1)

Why don’t we do it again? This time let’s inject on both the key and the ciphertext, and do the decryption.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
=   P0+perm[0](1)           →   P0''

: P0+perm[0](1) → P0''
↑perm[0] key add
: P0+perm[0](K) → P0''+perm[0](K)
↑16 sub[1]-perm[2]
: P1 → P1+perm[3](1)
↑perm[3] key add
: P1+perm[3](K+1) → P1+perm[3](K+1)
↑16 sub[4]-perm[5]
: P2' → P2'
↑perm[6] key add
: P2'+perm[6](K+1) → P2'+perm[6](K)

↑ P2'+perm[6](K+1), K+1 → P2'+perm[6](K), K
Decryption

This means a lot: now we have a pair of $(P_0, P_0’’)$ which only have 1 bit difference after the first round of encryption. Let take another step forward…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Encryption
↓ P0'', K → P0''+perm[0](1), K+1

: P0'' → P0''+perm[0](1)
↓perm[0] key add
: P0''+perm[0](K) → P0''+perm[0](1)+perm[0](K+1) = P0''+perm[0](K)
↓16 sub[1]-perm[2]
: P1+perm[3](1) → P1+perm[3](1)
↓perm[3] key add
: P1+perm[3](K+1) → P1+perm[3](K+1)+perm[3](K+1) = P1
↓16 sub[4]-perm[5]
: P2' → P2
↓perm[6] key add
: P2'+perm[6](K) → P2+perm[6](K+1) = P2+perm[6](K)+perm[6](1)

= P2'+perm[6](K) → P2+perm[6](K)+perm[6](1)

Finally, we manage to obtain a ciphertext with 1 bit difference compared to the original one. Based on this we can construct a distinguisher with our additional fault injection:

  1. Encrypt a random plaintext with the original key.
  2. Inject 1 bit additional fault on the i-th bit of the key.
  3. Obtain the magic ciphertext with 1 bit fault injected.

If success, the additional fault on the i-th bit equals to a 1 bit xor fault. For most parts of the key, this implies the i-th bit is 0 (with high probability). Injection on the MSB may always cause 1 bit fault so we need to bruteforce it instead.

(*)ella

No need for any paper, it’s just parity statistics.

Sadly i couldn't able to solve this chall .

day3

NoisyChannel

Our Solution

Well this problem has the less solution among the problems i have able to solved , I got 3rd blood in it just after the r3kapig. So It turns out that randbytes is implemented with getrandbits, with MT19937 as the prng core.

1
2
3
4
5
# random.py

def randbytes(self, n):
"""Generate n random bytes."""
return self.getrandbits(n * 8).to_bytes(n, 'little')

Our target is to find a kernel vector that can be used for any random seeds. If this is even possible, it implies we are dealing with a non-full-rank linear system. Therefore, we can randomly generate sets of data, concatenate them, and then extract a non-trivial vector from its left kernel space as the answer.

(*)LCC LLC

Here comes a McEliece challenge.

Decode Failure Oracle

Core functions below.

The server uses McEliece to generate access tokens, and our target is to forge a valid token with admin privilege.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class McEliece(BinaryGoppaCode):
def Encrypt(self, msg: bytes, eloc: List[int] = None) -> bytes:
""" Encrypts a message. """
assert 8 * len(msg) <= self.k
mvec = Bytes2BitVec(msg, self.k)
if eloc is None:
evec = self._RandomMaxWeight()
else:
evec = Matrix(GF(2), [1 if i in eloc else 0 for i in range(self.n)])
cvec = mvec * self.maskedG + evec
return BitVec2Bytes(cvec)

def Decrypt(self, cip: bytes) -> Tuple[bytes, set]:
""" Decrypts a ciphertext. """
assert len(cip) <= -(-self.n // 8)
cvec = Bytes2BitVec(cip, self.n)
mvec, evec = self.DecodeCodeWord(cvec * self.Pinv)
eloc = set([i for i,j in enumerate((evec * self.P).list()) if j])
return BitVec2Bytes(mvec * self.Sinv), eloc

class LCC:
def GenerateToken(self, username: str, admin: bool = False) -> str:
""" Generates a token. """
salt = os.urandom(24)
token = json.dumps({
'username' : username,
'admin' : admin,
'salt' : B64Encode(salt)
}).encode()
timestamp, eloc, etag = self.HashErrorLocs(salt)
encToken = self.code.Encrypt(token, eloc)
return '.'.join(B64Encode(i) for i in [timestamp, salt, etag, encToken])

def LoadToken(self, token: str) -> dict:
""" Loads a token. """
try:
timestamp, salt, etag, encToken = [B64Decode(i) for i in token.split('.')]
decToken, eloc = self.code.Decrypt(encToken)
loadedToken = json.loads(decToken.strip(b'\0'))
except:
raise ValueError('Failed to load token...')
# _, _eloc, _etag = self.HashErrorLocs(salt, timestamp)
try:
# assert _eloc == eloc
# assert _etag == etag
assert salt == B64Decode(loadedToken['salt'])
except:
raise ValueError('Invalid token...')
return loadedToken

# Homepage
@app.route('/', methods=['GET'])
def index():
token = request.cookies.get('token')
if token:
try:
userData = lcc.LoadToken(token)
flash('Succesfully loaded your token.', 'success')
if userData['admin'] == True:
content = FLAG.decode()
else:
content = 'Nothing to see here. Where you looking for something?'
return render_template(
'index.html',
username = ' ' + userData['username'],
content = content
)
except ValueError as e:
flash('ERROR:: {}'.format(str(e)), 'error')
except Exception as e:
print('ERROR:: {}'.format(str(e)))
flash('Something went wrong...', 'error')
else:
flash('No token found. Get a token by visiting "./gettoken/<username>".', 'warning')
return render_template('index.html', username='', content='Please load your token or generate a new one.')

There’s not so much we can do actually, since we’re only provided with some partly controllable tokens (even without the public key). So sadly i couldn't able to solve this one :") oh i also solved 2 forensics and 1 rev challenge as well..

In Saudi Arabia

Still can’t believe we did it, my teammates are reewnat, tanskit from uzbekistan and kruknight from Egypt. Got some luck too I guess .

team1

Met so many old and new friends here, enjoyed.meet with some dream teams *0xA, r3kapig, bios, hacksec etc

But 3-day competition is really tiring and we didn't have so much or energy to travel around. Will do more exploitation in the city if I come next year.

2

3

4

5