Jekyll2023-08-22T02:45:31+05:30https://deut-erium.github.io/WriteUps/feed.xmlCTF WriteupsHimanshu Sheoran deut-erium deuterium cryptography Capture The Flag CTF hacking cybersecurity SAT SMT solvers and computer science deuteriumfarziemailid69@gmail.comNullcon HackIM 2023 Crypto - Curvy Decryptor2023-08-21T00:00:00+05:302023-08-21T00:00:00+05:30https://deut-erium.github.io/WriteUps/2023/nullcon_hackim/crypto/curvy_decryptor/Nullcon-HackIM-Curvy-Decryptor

## Challenge Description

Curvy Decryptor 473 points Alice has hidden 2 flags in this challenge. And even though she is willing to decrypt most ciphers, she has some basic saveguards against stealing flags. Please submit flag1 here. nc 52.59.124.14 10005

## Source Analysis

From curvy_decryptor.py

#!/usr/bin/env python3
import os
import sys
import string
from Crypto.Util import number
from Crypto.Util.number import bytes_to_long, long_to_bytes
from Crypto.Cipher import AES
from binascii import hexlify

from ec import *
from utils import *
from secret import flag1, flag2

#P-256 parameters
p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
a = -3
b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
curve = EllipticCurve(p,a,b, order = n)
G = ECPoint(curve, 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296, 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5)

d_a = bytes_to_long(os.urandom(32))
P_a = G * d_a

printable = [ord(char.encode()) for char in string.printable]

def encrypt(msg : bytes, pubkey : ECPoint):
x = bytes_to_long(msg)
y = modular_sqrt(x**3 + a*x + b, p)
m = ECPoint(curve, x, y)
d_b = number.getRandomRange(0,n)
return (G * d_b, m + (pubkey * d_b))

def decrypt(B : ECPoint, c : ECPoint, d_a : int):
if B.inf or c.inf: return b''
return long_to_bytes((c - (B * d_a)).x)

def loop():
print('I will decrypt anythin as long as it does not talk about flags.')
balance = 1024
while True:
print('B:', end = '')
sys.stdout.flush()
print('c:', end = '')
sys.stdout.flush()
B = ECPoint(curve, *[int(_) for _ in B_input.split(',')])
c = ECPoint(curve, *[int(_) for _ in c_input.split(',')])
msg = decrypt(B, c, d_a)
if b'ENO' in msg:
balance = -1
else:
balance -= 1 + len([c for c in msg if c in printable])
if balance >= 0:
print(hexlify(msg))
print('balance left: %d' % balance)
else:
print('You cannot afford any more decryptions.')
return

if __name__ == '__main__':
print('My public key is:')
print(P_a)
print('Good luck decrypting this cipher.')
B,c = encrypt(flag1, P_a)
print(B)
print(c)
key = long_to_bytes((d_a >> (8*16)) ^ (d_a & 0xffffffffffffffffffffffffffffffff))
enc = AES.new(key, AES.MODE_ECB)
cipher = enc.encrypt(flag2)
print(hexlify(cipher).decode())
try:
loop()
except Exception as err:
print(repr(err))


## Curvy Decryptor part 1

The solution of Curvy Decryptor part 1 is to find out flag1

## Curvy Decryptor part 2

The solution of Curvy Decryptor part 1 is to find out flag2
The flag2 appears to be AES encrypted with a key which is

key = long_to_bytes((d_a >> (8*16)) ^ (d_a & 0xffffffffffffffffffffffffffffffff))


The xor of most significant and least significant 64 bits of the 128 bits private key d_a
So in order to recover the flag2 we will need to break the ECC and recover d_a

### Analysing curvy_decryptor.py

#P-256 parameters
p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
a = -3
b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
curve = EllipticCurve(p,a,b, order = n)
G = ECPoint(curve, 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296, 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5)

d_a = bytes_to_long(os.urandom(32))
P_a = G * d_a


indeed look like a standard curve P-256 (note that a = -3 is equivalent to a = p - 3)
So there doesnt appear to be any standard weakness based on weak curve parameters.
The private key d_a is initialized to be per-instance system-random 128 bit number
And the Public key P_a is simply G*d_a

#### Encryption

def encrypt(msg : bytes, pubkey : ECPoint):
x = bytes_to_long(msg)
y = modular_sqrt(x**3 + a*x + b, p)
m = ECPoint(curve, x, y)
d_b = number.getRandomRange(0,n)
return (G * d_b, m + (pubkey * d_b))


The msg is encoded as the x coordinate of the point, the corresponding y is found so as to find the point on the curve to generate the point m
A secure random number d_b in range (0 - curve order) is generated as the nonce, The points G*d_b and m + (pubkey * d_b) are returned

#### Decryption

def decrypt(B : ECPoint, c : ECPoint, d_a : int):
if B.inf or c.inf: return b''
return long_to_bytes((c - (B * d_a)).x)


It simply reverses the encrypt function if correct d_a is provided
i.e if we do

B,c = encrypt(flag1, P_a)
xx = decrypt(B,c)


We will get,

$\text{decrypt}((G * d_b * d_a), \text{flag}_m + (P_a * d_b))$ $= (\text{flag}_m + (P_a * d_b) - G * d_b * d_a).x$ $= (\text{flag}_m + G * d_a * d_b - G * d_b * d_a).x$ $= (\text{flag}_m).x = \text{flag}$

#### Main Loop

The main loop of the program just repeatedly asks for input of two EC points B and c and tries to decrypt it with the servers private key d_a
BUT
if the decryption contains b'ENO' i.e the start of the flag, it exits.
Otherwise it decreases the balance proportionate to the number of printable characters in the decryption.

So If we directly input the B and c corresponding to the flag1, it will abort and hence no flags for us :’(

But it doesnt care what points we ask it to decrypt.
So what if we try to decrypt the points B, c + A

We will get,

$\text{decrypt}((G * d_b * d_a), \text{flag}_m + (P_a * d_b) + A)$ $= (\text{flag}_m + (P_a * d_b) + A - G * d_b * d_a).x$ $= (\text{flag}_m + A + G * d_a * d_b - G * d_b * d_a).x$ $= (\text{flag}_m + A).x$

As long as we know A, we can always get the point from the x coordinate, and subtract A from it to get the original point from the curve for the sake of simplicity, we can even pick it to be G

or we can even try decrypting the points B + A, c which will lead to

$\text{decrypt}((G * d_b * d_a) + A, \text{flag}_m + (P_a * d_b))$ $= (\text{flag}_m + (P_a * d_b) - (G * d_b + A) * d_a).x$ $= (\text{flag}_m + G * d_a * d_b - G * d_b * d_a - A * d_a).x$ $= (\text{flag}_m - A * d_a).x$

if we choose A to be G or -G, we will end up with the point flag - P_a or flag + P_a and since we even know P_a, it will also work
With a high probability, we wont observe any b'ENO' in the resulting point, and if we do, we can always pick countless possibilities of A to make it work

from ec import *
from utils import *
import pwn

p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
a = -3
b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
curve = EllipticCurve(p,a,b, order = n)
G = ECPoint(curve, 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296, 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5)

HOST, PORT = "52.59.124.14", 10005
REM = pwn.remote(HOST, PORT)

REM.recvline() # My public key is:
pubkey = REM.recvline().strip()[6:-1].split(b',')
P_a = ECPoint(curve, int(pubkey), int(pubkey))

REM.recvline() # Good luck decrypting this cipher.
B_text = REM.recvline().strip()[6:-1].split(b',')
B = ECPoint(curve, int(B_text), int(B_text))

c_text = REM.recvline().strip()[6:-1].split(b',')
c = ECPoint(curve, int(c_text), int(c_text))

flag2_enc = bytes.fromhex(REM.recvline().strip().decode())

REM.recvline() # I will decrypt anythin as long as it does not talk about flags.

def get_decryption(B,c):
REM.sendline("{},{}".format(B.x, B.y))
REM.sendline("{},{}".format(c.x, c.y))
status = REM.recvline()
if b'cannot afford' in status:
return -1, None
balance = int(REM.recvline().strip().split(b': ')[-1])
return balance, bytes.fromhex(status.strip()[6:-1].decode())

bal, BG = get_decryption(B, c+G)
BG_int = int.from_bytes(BG)
y = modular_sqrt(BG_int**3+a*BG_int+b,p)  # getting the valid y coordinate for the x
point_BG1 = ECPoint(curve, BG_int, y)
point_BG2 = -point_BG1
print(int.to_bytes((point_BG1-G).x, 32,'big'))
print(int.to_bytes((point_BG2-G).x, 32,'big'))


Note that we only get the x coordinate of the given point lifting the point would result in two points and we will need to try with both of them (x,y) and (x,-y)

The last part will also work with

bal, BG = get_decryption(B-G, c)
BG_int = int.from_bytes(BG)
y = modular_sqrt(BG_int**3+a*BG_int+b,p)  # getting the valid y coordinate for the x
point_BG1 = ECPoint(curve, BG_int, y)
point_BG2 = -point_BG1
print(int.to_bytes((point_BG1-P_a).x, 32,'big'))
print(int.to_bytes((point_BG2-P_a).x, 32,'big'))


And we get the flag to part 1 b'\x00\x00ENO{ElGam4l_1s_mult1pl1cativ3}'

### Analysing ec.py

from Crypto.Util.number import inverse

class EllipticCurve(object):
def __init__(self, p, a, b, order = None):
self.p = p
self.a = a
self.b = b
self.n = order

def __str__(self):
return 'y^2 = x^3 + %dx + %d modulo %d' % (self.a, self.b, self.p)

def __eq__(self, other):
return (self.a, self.b, self.p) == (other.a, other.b, other.p)

class ECPoint(object):
def __init__(self, curve, x, y, inf = False):
self.x = x % curve.p
self.y = y % curve.p
self.curve = curve
self.inf = inf
if x == 0 and y == 0: self.inf = True

def copy(self):
return ECPoint(self.curve, self.x, self.y)

def __neg__(self):
return ECPoint(self.curve, self.x, -self.y, self.inf)

p = self.curve.p
if self.inf:
return point.copy()
if point.inf:
return self.copy()
if self.x == point.x and (self.y + point.y) % p == 0:
return ECPoint(self.curve, 0, 0, True)
if self.x == point.x:
lamb = (3*self.x**2 + self.curve.a) * inverse(2 * self.y, p) % p
else:
lamb = (point.y - self.y) * inverse(point.x - self.x, p) % p
x = (lamb**2 - self.x - point.x) % p
y = (lamb * (self.x - x) - self.y) % p
return ECPoint(self.curve,x,y)

def __sub__(self, point):
return self + (-point)

def __str__(self):
if self.inf: return 'Point(inf)'
return 'Point(%d, %d)' % (self.x, self.y)

def __mul__(self, k):
k = int(k)
base = self.copy()
res = ECPoint(self.curve, 0,0,True)
while k > 0:
if k & 1:
res = res + base
base = base + base
k >>= 1
return res

def __eq__(self, point):
return (self.inf and point.inf) or (self.x == point.x and self.y == point.y)



Looking closely at the ECPoint class, one would note that on the initialization of the point with arbitrary x, y coordinates, it works as usual and doesnt check whether the supplied x, y satisfy the curve equation $y^2 = x^3 + ax + b \mod p$

This leads to an interesting vulnerability aka Invalid Curve Attack

Which can be noted by the facts that

1. The point addtion of two points $P$ and $Q$ over the curve $y^2 = x^3 + ax + b \mod p$if $P \ne Q$ is independent of both the curve parameters $a$ and $b$
2. The point doubling i.e $P = Q$ is just dependent on $P$ and $Q$ and $a$ but not on $b$ again

This means that the group addition operation is independent of the parameter $b$, the number of points in the group of some point $P$ is just dependent on $P$ and $a$ but independent of $b$

So if we choose a curve $C’$ with parameter $b’$ and pick a valid point $P’$ on it, and run the point addition over the original curve $C$, the order of point $P’$ when used on $C$ will be the same as order of point $P’$ when used on $C’$

This implies that we are not stuck with the original prime order of P-256, but we can vary $b = -3$ such that the order of the curve $C’$ has small factors. We can then easily find points $P’$ with those small factors as their order by using the fact that -
If $G’$ is the generator of $C’$ with order $o = f_1f_2f_3…f_n$, the point $G’ * (o/f_1)$ will have the order $f_1$

Once we have sufficient number of small orders, we can take a chinese remainder theorem over them to recover the original private key d_a

To find the order of curve $C’$ and the corresponding generators, we can utilize the greate library of sagemath

def get_invalid_curves(a,b,n,cutoff=10**5):
factors, total, i = {}, 0, 0
while total < n*2:
i += 1
try:
E = EllipticCurve(GF(p), [a,i])
order = E.order()
n_facs = order.factor()
except ArithmeticError: #the parameter i defines a singular curve
continue
for prime, power in n_facs:
if prime > cutoff: # dont take any factors bigger than it
break
gen = E.gen(0)*(order//prime)
factors[prime] = [int(gen), int(gen),i]
total *= prime
print(i, total)
# key : [gen_x, gen_y, b']
return factors


And we can easily bruteforce a given point

def bruteforce(point, generator, order):
for i in tqdm(range(order),desc=f"bruteforcing {order=}"):
if point.y == (generator*i).y:
return i


We get the following invalid curves and we just searched over 12 values of $b$!

invalid_curves = {
3: [79692280239272980873245387831874823476097665365069163558817570386218657526967,
15885657487155030912288031888128427124936813080859472376494663130798867119982, 12],
5: [30463586456259052716174121724723788478797318939762291523651966151233767925799,
14521026652335616630611219515390291411023400641948957680811406721043438902186, 12],
7: [7494160963166719022445789448670075468300539216220596044581361034311676798234,
98511591492536111584332615786483658886888663084020823615343609813895225923287, 0],
13: [44238399751822344629155927349410921734336660036385908812849527496419061724190,
111209137730801733774021088162408683888753865848469665403753663663899601005809, 3],
17: [9274144687945784364291903707116312659963917031850121885976057784793297477861,
86301980488975426887521079169756244075123784201450393961147458432303011672794, 10],
19: [108657251488837839710095894743866739052486880271258033613510419634191398226376,
8593905934316229092193387452437731577526088690676465668457131094758391852209, 4],
37: [6829338390266237482283310246665103308891228336319477318479644522260556056309,
74701668267551028200304338837410580676774474001240882947774298643661074111881, 3],
71: [47689891150662520216418276050802771367044708366511766907543187521601344242001,
76986723505258444611874235435887405018513758524921757765033540214285112109625, 1],
97: [109352438132789597676269849271161933029115963700376783044214805643475162939438,
40494199582133551395560104592591896448854412368997470369685720658455096277720, 3],
113: [24758423058742208238204864443231318968571918830166822957638906079202832915346,
64680694216633390865014362020707904150333340051904806580803858267985332824228, 3],
179: [83115631016490504822328655777895864162660782325660359674792065332260812135544,
35707141486916353816358123900356888673488260709581875628819622187261352405569, 4],
251: [22589597796365257246296758128505770638799961769310824687868041010311103597978,
22730375842099404560129412560882492093998724011749582473003682871994848593450, 10],
389: [61737418306809996908630595437832052272700263892021361415640028314169193468679,
38812622012907358702971098652989910931710935180589252493039839244464470874161, 12],
653: [96946680343613920300091880607027973891460464096741803273170797219756170839615,
82843786165425711709725308210080928839669646246950072002603300248219042690214, 9],
823: [101591078169875595753109991494905506967978136240961172388509275621325253165256,
10303457253039461887297603877250453486351456285884301634668455036581356870325, 1],
1151: [4942947285962241518079147671001480777229821084370946279070977308149340420785,
16048516228456466259745940658231903287363041924322695524263376011778714624389, 7],
1229: [91709248688928381574970306879959143256779911355970659117798234761550615769703,
85856020107303390216619790339131383987393998107943546966635616939179964220668, 1],
2447: [107091037109612570995136294213336682923913717986054179094643922074841981090569,
38297847735446351346601186761335949464902974429727652825128988635682228100545, 5],
4003: [69634612360547639692978050736475584000001950346963254134893659331303767659709,
7267690154676021708711188497795027916155791784399213931355851351510639163175, 6],
7103: [110323527740892356276833844768860449554291010208201255792825053403232044044793,
34702628194678317337541067016532288256370093140368313590333192034888987762792, 7],
7489: [69497610789705595174058737106242513100950130190920702467431032172354669590563,
43000989776377667520933328800675765150040604546037676698173382008099239610730, 1],
13003: [69994388431307856080322572731970917270151067511018517619530568914812259046195,
51645889020375608054366335957352074257130320976341249224010343992676177045239, 4],
16033: [80150849770701280770379260802876332257245651607220436873841708569583336291111,
67454443034144602807761039494179913732280388550455172486599878108995578176702, 6],
19423: [86763316696116146207846209443089376095966542281990071872698734124275764832625,
1497373281188841342082112917519408391664673991593710756225816313602284346637, 11],
30203: [27140306769124364253212826889951250714782929180685455599284687702513066987645,
27465668374540052358785326933904597047991904030378513297677516281690992773738, 1],
52183: [20786893006200668135980517481305198967871522130773700571327256180224225598537,
38476450712159672989047119873673988596095516096648184067210103163599625447149, 12],
72337: [16864673136043278693040185572303485743677125999233419976437302471094264721938,
39982110747848740588957884598316010802865483814669576940304708444368796141014, 9],
81173: [41965847134675666863089670621412699297207446259915277832939899605119013001686,
97595102592346869875749873612676528534971198259624427507680148771098555985918, 8]
}


### Attack procedure

1. For each $b’$ and $G’$ of order $o’$
2. Send $B = -G’$ and $c = G’$ we will get back x coordinate of $G’ * (d_a + 1)$

Note that if we send 0, it will just return b'' so no use

3. Lift the x coordinate Over C’ to get $G’ * (d_a + 1)$ But this wont help us to figure out of the two possible points. For this
4. Send another $B = -G’$ and $c = G’ * 2$ to get back x coordinate of $G’ * (d_a + 2)$ To figure out the correct lifting of the x coordinate to get $G’ * (d_a + 1)$
5. Bruteforce and recover the ECDLP to get $d_a’ = (d_a + 1) \mod o’$
6. Combine all $d_a’$ using chinese remainder theorem to get back original $d_a$
7. PROFIT ????
recovered_order = {}
for order, (gen_x, gen_y, b_i) in invalid_curves.items():
gen = ECPoint(curve, gen_x, gen_y)
bal, BG = get_decryption(-gen, gen) # gen*(da+1)
BG_int = int.from_bytes(BG)
y = modular_sqrt(BG_int**3+a*BG_int+b_i,p)
point_BG = ECPoint(curve, BG_int, y)

bal, BG2 = get_decryption(-gen, gen*2) # gen*(da+2)
BG_int2 = int.from_bytes(BG2)
if (point_BG+gen).x == BG_int2:
recovered_order[order] = bruteforce(point_BG, gen, order)
elif (point_BG-gen).x == BG_int2:
recovered_order[order] = bruteforce(-point_BG, gen, order)
else:
print("something went wrong")

mods, values = [],[]
for i,v in recovered_order.items():
mods.append(i)
values.append((v-1)%i)

d_a = crt(mods,values)

key_int = (d_a >> (8*16)) ^ (d_a & 0xffffffffffffffffffffffffffffffff)
key = key_int.to_bytes(16, 'big')
print(AES.new(key, AES.MODE_ECB).decrypt(flag2_enc))


## Solve script

solve.py

]]>
deuteriumfarziemailid69@gmail.com
ACSC qualifiers 2023 Crypto - SusCipher2023-03-01T00:00:00+05:302023-03-01T00:00:00+05:30https://deut-erium.github.io/WriteUps/2023/ACSC/crypto/SusCipher/ACSC-SusCipher

## Challenge Description

SusCipher 400 pts (6 solves)

authored by rbtree

I made SusCipher, which is a vulnerable block cipher so everyone can break it!

Please, try it and find a key. nc suscipher.chal.ctf.acsc.asia 13579 nc suscipher-2.chal.ctf.acsc.asia 13579 (Backup) Hint: Differential cryptanalysis is useful. SusCipher.tar.gz

## Source Analysis


#!/usr/bin/env python3
import hashlib
import os
import signal

class SusCipher:
S = [
43,  8, 57, 53, 48, 39, 15, 61,
7, 44, 33,  9, 19, 41,  3, 14,
42, 51,  6,  2, 49, 28, 55, 31,
0,  4, 30,  1, 59, 50, 35, 47,
25, 16, 37, 27, 10, 54, 26, 58,
62, 13, 18, 22, 21, 24, 12, 20,
29, 38, 23, 32, 60, 34,  5, 11,
45, 63, 40, 46, 52, 36, 17, 56
]

P = [
21,  8, 23,  6,  7, 15,
22, 13, 19, 16, 25, 28,
31, 32, 34, 36,  3, 39,
29, 26, 24,  1, 43, 35,
45, 12, 47, 17, 14, 11,
27, 37, 41, 38, 40, 20,
2,  0,  5,  4, 42, 18,
44, 30, 46, 33,  9, 10
]

ROUND = 3
BLOCK_NUM = 8
MASK = (1 << (6 * BLOCK_NUM)) - 1

@classmethod
def _divide(cls, v: int) -> list[int]:
l: list[int] = []
for _ in range(cls.BLOCK_NUM):
l.append(v & 0b111111)
v >>= 6
return l[::-1]

@staticmethod
def _combine(block: list[int]) -> int:
res = 0
for v in block:
res <<= 6
res |= v
return res

@classmethod
def _sub(cls, block: list[int]) -> list[int]:
return [cls.S[v] for v in block]

@classmethod
def _perm(cls, block: list[int]) -> list[int]:
bits = ""
for b in block:
bits += f"{b:06b}"

buf = ["_" for _ in range(6 * cls.BLOCK_NUM)]
for i in range(6 * cls.BLOCK_NUM):
buf[cls.P[i]] = bits[i]

permd = "".join(buf)
return [int(permd[i : i + 6], 2) for i in range(0, 6 * cls.BLOCK_NUM, 6)]

@staticmethod
def _xor(a: list[int], b: list[int]) -> list[int]:
return [x ^ y for x, y in zip(a, b)]

def __init__(self, key: int):
assert 0 <= key <= self.MASK

keys = [key]
for _ in range(self.ROUND):
v = hashlib.sha256(str(keys[-1]).encode()).digest()
v = int.from_bytes(v, "big") & self.MASK
keys.append(v)

self.subkeys = [self._divide(k) for k in keys]

def encrypt(self, inp: int) -> int:
block = self._divide(inp)

block = self._xor(block, self.subkeys)
for r in range(self.ROUND):
block = self._sub(block)
block = self._perm(block)
block = self._xor(block, self.subkeys[r + 1])

return self._combine(block)

# TODO: Implement decryption
def decrypt(self, inp: int) -> int:
raise NotImplementedError()

def handler(_signum, _frame):
print("Time out!")
exit(0)

def main():
signal.signal(signal.SIGALRM, handler)
signal.alarm(300)
key = int.from_bytes(os.urandom(6), "big")

cipher = SusCipher(key)

while True:
inp = input("> ")

try:
l = [int(v.strip()) for v in inp.split(",")]
except ValueError:
print("Wrong input!")
exit(0)

if len(l) > 0x100:
print("Long input!")
exit(0)

if len(l) == 1 and l == key:
with open('flag', 'r') as f:

print(", ".join(str(cipher.encrypt(v)) for v in l))

if __name__ == "__main__":
main()


Let’s take a look at the relevant parts

### main

While True, it asks for an input which is a string of numbers separated by , As long as we input 0x100 or 256 numbers at a time, we can get as many encryptions as we like

If we only enter a single number, and if that number happens to be the secret round key, we can get the flag

Sounds easy? lets take a look into the cipher

### The Cipher

The above construction is Substitution Permutation Network (SPN) which is essentially a repeated operation of substitution with a fixed predefined array
here which is

    S = [
43,  8, 57, 53, 48, 39, 15, 61,
7, 44, 33,  9, 19, 41,  3, 14,
42, 51,  6,  2, 49, 28, 55, 31,
0,  4, 30,  1, 59, 50, 35, 47,
25, 16, 37, 27, 10, 54, 26, 58,
62, 13, 18, 22, 21, 24, 12, 20,
29, 38, 23, 32, 60, 34,  5, 11,
45, 63, 40, 46, 52, 36, 17, 56
]


I.e a number n is replaced with S[n]

followed by a permutation of bits here defined by

    P = [
21,  8, 23,  6,  7, 15,
22, 13, 19, 16, 25, 28,
31, 32, 34, 36,  3, 39,
29, 26, 24,  1, 43, 35,
45, 12, 47, 17, 14, 11,
27, 37, 41, 38, 40, 20,
2,  0,  5,  4, 42, 18,
44, 30, 46, 33,  9, 10
]


Which means P[t]th bit of the output is actually the tth bit of inpute.g. P = 21 means the 21th bit of output is 0th bit of input as is

followed by a xor operation with a secret key

This process is repeated a fixed number of rounds times with a new secret key each round (which are called the round keys or subkeys)

The encrypt function hence looks like as below

    def encrypt(self, inp: int) -> int:
block = self._divide(inp)

block = self._xor(block, self.subkeys)
for r in range(self.ROUND):
block = self._sub(block)
block = self._perm(block)
block = self._xor(block, self.subkeys[r + 1])

return self._combine(block)


(_divide and _combine are just helper functions to make programmers life easier)

One might question, why are we _dividing a good enough input of 48 bits into 8 chunks of 6 bits each? Well, in an ideal world, we would like to have a substitution box of 48 bits, but that would eat up a whopping 2^48 number of entries (which we are somehow fooling with 2^6 entries here

Hence the functions _sub acts as if it sees 8 different values and substitutes them and acts as if it just did 48 bits of substitution

    @classmethod
def _sub(cls, block: list[int]) -> list[int]:
return [cls.S[v] for v in block]


So does _perm pretend (because of our design) that it sees a big block of 48 bits which it permutes to a block of 48 bits, but what it does is to take 8 blocks of 6 bits each and create 8 blocks of 6 bits each if they were all connected

    @classmethod
def _perm(cls, block: list[int]) -> list[int]:
bits = ""
for b in block:
bits += f"{b:06b}"

buf = ["_" for _ in range(6 * cls.BLOCK_NUM)]
for i in range(6 * cls.BLOCK_NUM):
buf[cls.P[i]] = bits[i]

permd = "".join(buf)
return [int(permd[i : i + 6], 2) for i in range(0, 6 * cls.BLOCK_NUM, 6)]


#### Where do subkeys come from?

    def __init__(self, key: int):
assert 0 <= key <= self.MASK

keys = [key]
for _ in range(self.ROUND):
v = hashlib.sha256(str(keys[-1]).encode()).digest()
v = int.from_bytes(v, "big") & self.MASK
keys.append(v)

self.subkeys = [self._divide(k) for k in keys]


As you may have observed from the init function, subkeys are “derived” from a single 48-bit key in a way that we cant recover subkey i from the knowledge of any of the subkeys j>i (to make the challenge hard so that we will definitely need to recover subkey which is the original key

## Vulnerability?

If you have seen some cipher constructions before, you may have observed, that the ROUND = 3 is really very low and 6-bit sboxes are still not as robust as you may imagine them to be.

Another hint as provided by the author is Differential Cryptanalysis, and since I am obsessed with SAT solvers, I will overlook the hint and cheeze it with z3

## Modelling

While the general methodology to solve a problem with a SAT solver is to write the output as a (symbolic) function of the inputs, and finding an input which leads to the observed output.

So what’s the symbolic input and output here?

For an input inp to the SusCipher(key) producing an encryption out We can write out as symbolic_function(subkeys, inp)

With subkeys acting as unknown inp which we aim for, we can easily get the desired outcome.

Taking heavy inspiration from the implementaion of the challenge cipher, we can similary create the z3 model of suscipher

class CrackSusCipher:
ROUND = 3
BLOCK_NUM = 8
def _divide(self, v):
l = []
for _ in range(self.BLOCK_NUM):
l.append(v&0b111111)
v >>=6
return l[::-1]

def _combine(self, block):
res = 0
for v in block:
res <<=6
res |= v
return res

def _xor(self, a,b):
return [x^y for x,y in zip(a,b)]


These functions look identical.

### Modelling substitution

First hurdle most of the people face modelling a SPN network or any other cipher is to model substitution.
But z3 is equipped with powerful theories of arrays (and functions)

Thus to model substitution, we can define a symbolic function S, which takes 6-bit inputs and generates 6-bit outputs

self.S = Function('S', BitVecSort(6), BitVecSort(6))


then self.S(i) would indeed be exactly what we desire
But wait, we just specified that S can be any function, not the exact substitution function we are provided with.
Worry not, we can specify this as a constraint to the solver

for i,v in enumerate(S): #original S as provided in the challenge


i.e we want S(0) to be nothing else than 43 and so on

And we treat keys as 6 bit unknowns, so there will be (ROUND+1)*8 variables.

Overall our init function will look like

    def __init__(self):
self.S = Function('S', BitVecSort(6), BitVecSort(6))
self.solver = Solver()
for i,v in enumerate(S):
self.keys = [[BitVec(f'k_{r}_{i}',6) for i in range(8) ] for r in range(self.ROUND+1)]


hence _sub function would be

    def _sub(self, block):
return [self.S(simplify(i)) for i in block]


Note that it could have been self.S(i) instead of self.S(simplify(i)) which I used, just to simplify the expression (if possible) before substituting to hopefully speed things up

### Modelling permutation

Now what about the permutation? We can model it exactly how we would have calculated a permutation Take the ith bit, put it P[i]th place in the output, just the way to deal with BitVectors vary

    def _perm(self, block):
x = Concat(block)
# treat the 8 6-bit vectors as a single 48 bit-vector
output = *48 # temporary placeholder for output
for i,v in enumerate(P):
# extract the ith bit from the MSB put it at the correct place
output[v] = Extract(47-i, 47-i, x)
# rechunk in 6 bit bitvectors
return [Concat(output[i:i+6]) for i in range(0,48,6)]


### Modelling encryption

Finally after getting the required blocks to perform our symbolic encryption, we can model it

    def enc(self, block):
block = self._xor(block, self.keys)
for r in range(self.ROUND):
block = self._sub(block)
block = self._perm(block)
block = self._xor(block, self.keys[r+1])
return block


Which you can see is almost like the original except we are not _dividing and _combining the 48-bits but rather assume that it operates on 8 6-bit values. And self.keys here are the symbolic unknowns.

### Checking if our model is correct

Now a CTF player will be anxious whether the efforts they put in to model the cipher were fruitful or did they mess up the model somewhere?

Worry not, we can always check our symbolic model by plugging in real values and comparing with the original cipher

We will use random values and keys just to check if they match (kinda funny that we have to informally verify a formal verifier XD)

print("verifying our modelling")
import random
for i in range(100):
random_key = random.randint(0,2**48-1)
sus = SusCipher(random_key)
sus_model = crack()
sus_model.solver.check() # to fill in the S as the original substitution function
sus_model.keys = [[BitVecVal(i,6) for i in row] for row in sus.subkeys] # BitVecVal as a symbolic constant value
for j in range(10):
inp = random.randint(0,2**48-1)
real_out = sus.encrypt(inp)
sym_out_chunks = sus_model.enc(sus_model._divide(inp))
# evaluating the symbolic output as per the symbolic model
sym_out = sus_model.solver.model().eval(Concat(sym_out_chunks))
assert sym_out.as_long() == real_out
print("success")


Taking care of the _divide business, we will equate the 6-bit chunks of the output and our symbolic output for a given input

    def add_sample(self, inp, oup):
for a,b in zip(self.enc(self._divide(inp)), self._divide(oup)):


### Getting the key

It’s really simple, just check if there is any satisfying model which would make our constraints possible, and get the first subkey according to that model

    def get(self):
if self.solver.check()==sat:
model = self.solver.model()
k = [model.eval(i).as_long() for i in self.keys]
return self._combine(k)


### Putting our class together


S = [
43,  8, 57, 53, 48, 39, 15, 61,
7, 44, 33,  9, 19, 41,  3, 14,
42, 51,  6,  2, 49, 28, 55, 31,
0,  4, 30,  1, 59, 50, 35, 47,
25, 16, 37, 27, 10, 54, 26, 58,
62, 13, 18, 22, 21, 24, 12, 20,
29, 38, 23, 32, 60, 34,  5, 11,
45, 63, 40, 46, 52, 36, 17, 56
]

P = [
21,  8, 23,  6,  7, 15,
22, 13, 19, 16, 25, 28,
31, 32, 34, 36,  3, 39,
29, 26, 24,  1, 43, 35,
45, 12, 47, 17, 14, 11,
27, 37, 41, 38, 40, 20,
2,  0,  5,  4, 42, 18,
44, 30, 46, 33,  9, 10
]

class crack:
ROUND = 3
BLOCK_NUM = 8
def __init__(self):
self.S = Function('S', BitVecSort(6), BitVecSort(6))
self.solver = Solver()
for i,v in enumerate(S):
self.keys = [[BitVec(f'k_{r}_{i}',6) for i in range(8) ] for r in range(self.ROUND+1)]

def _divide(self, v):
l = []
for _ in range(self.BLOCK_NUM):
l.append(v&0b111111)
v >>=6
return l[::-1]

def _combine(self, block):
res = 0
for v in block:
res <<=6
res |= v
return res

def _xor(self, a,b):
return [x^y for x,y in zip(a,b)]

def _perm(self, block):
x = Concat(block)
output = *48
for i,v in enumerate(P):
output[v] = Extract(47-i, 47-i, x)
return [Concat(output[i:i+6]) for i in range(0,48,6)]

def _sub(self, block):
return [self.S(simplify(i)) for i in block]

def enc(self, block):
block = self._xor(block, self.keys)
for r in range(self.ROUND):
block = self._sub(block)
block = self._perm(block)
block = self._xor(block, self.keys[r+1])
return block

for a,b in zip(self.enc(self._divide(inp)), self._divide(oup)):

def get(self):
if self.solver.check()==sat:
model = self.solver.model()
k = [model.eval(i).as_long() for i in self.keys]
return self._combine(k)


So how many input-output pairs do we need to figure out the key uniquely?
I guess atmost 256?
Let’s try it out

c = crack()
for i in range(256):
input = random.randint(0,2**48-1)
output = server(input) #whatever

key = c.get()


Hmmm, something’s not right, it seems to be stuck indefinitely.
We can get the intuition of difficulty of the solver to find key by reducing the number of constraints i.e the number of input output pairs.

By playing around, one quickly comes to the realisation that it wont workeven for 5 random samples and will time out >200s

### Moment of inspiration

When we take a random input-output pair, what we ask the solver for Substitution(key[i] ^ some_random)
But if it were just 0 instead of some_random, it would have to guess one less step.
So how about we make 7 out of 8 0 and only keep one key place active in substitution?.

This is really easy with input = (1<<i) for (0<=i<48)

And most importantly, it works!
(To an amazement that it works in around a second with 48 samples as opposed to ~5000 seconds for 5 random samples!)

### Getting the flag

import pwn
HOST, PORT = "suscipher.chal.ctf.acsc.asia", 13579
REM = pwn.remote(HOST, PORT)

REM.sendline(",".join(str((1<<i)) for i in range(48)))
response = list(map(int,REM.recvline()[2:].strip().split(b', ')))
c = crack()
for i,v in enumerate(response):

key = c.get()
REM.sendline(str(key))
REM.interactive()


#### ACSC{There_may_be_a_better_solution_to_solve_this_but_I_used_diff_analysis_:(}

As expected, the author knew there might be other interesting ways like this one ;)

### Get the Solve Script

]]>
deuteriumfarziemailid69@gmail.com
Cyber Apocalypse HTB 2022 Crypto - Memory Acceleration2022-05-21T00:00:00+05:302022-05-21T00:00:00+05:30https://deut-erium.github.io/WriteUps/2022/cyber_apocalypse/crypto/memory_acceleration/HTB-Cyber-Apcalypse-2022-Memory-Acceleration

## Source Analysis

From source.py

import socketserver
import signal
from pofwork import phash

DEBUG_MSG = "DEBUG MSG - "
WELCOME_MSG = """Virgil says:
Klaus I'm connecting the serial debugger to your memory.
Please stay still. We don't want anything wrong to happen.
Ok you should be able to see debug messages now..\n\n"""

with open('memories.txt', 'r') as f:
MEMORIES = [m.strip() for m in f.readlines()]

class Handler(socketserver.BaseRequestHandler):
def handle(self):
signal.alarm(0)
main(self.request)

class ReusableTCPServer(socketserver.ForkingMixIn, socketserver.TCPServer):
pass

def sendMessage(s, msg):
s.send(msg.encode())

def recieveMessage(s, msg):
sendMessage(s, msg)
return s.recv(4096).decode().strip()

def main(s):
block = ""
counter = 0
sendMessage(s, WELCOME_MSG)

while True:
block += MEMORIES[counter]

sendMessage(s, DEBUG_MSG +
f"You need to validate this memory block: {block}\n")

first_key = recieveMessage(s, DEBUG_MSG + "Enter first key: ")
second_key = recieveMessage(s, DEBUG_MSG + "Enter second key: ")

try:
first_key, second_key = int(first_key), int(second_key)
proof_of_work = phash(block, first_key, second_key)
except:
sendMessage(s, "\nVirgil says: \n"
"Be carefull Klaus!! You don't want to damage yourself.\n"
"Let's start over.")
exit()

if proof_of_work == 0:
block += f" ({first_key}, {second_key}). "
sendMessage(s, "\nVirgil says: \nWow you formed a new memory!!\n")
counter += 1
sendMessage(
s, f"Let's try again {4 - counter} times just to be sure!\n\n")
else:
sendMessage(s, DEBUG_MSG + f"Incorect proof of work\n"
"\nVirgil says: \n"
"You calculated something wrong Klaus we need to start over.")
exit()

if counter == 4:
sendMessage(s, "It seems that everything are working fine.\n"
"Wait what is that...\n"
"Klaus this is important!!\n"
f"{MEMORIES[-1]}")
exit()

if __name__ == '__main__':
server = ReusableTCPServer(("0.0.0.0", 1337), Handler)
server.serve_forever()


We have to provide two integers first_key and second_key such that
phash(block, first_key, second_key) == 0. block will be presented by the challenge server. If we do it 4 times, we get our flag.
It’s roughly like how we validate blocks with Proof-of-Work in blockchains

Lets take a look at phash from pofwork.py

from hashlib import md5
from Crypto.Util.number import long_to_bytes, bytes_to_long

sbox = [
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
]

def rotl(n, b):
return ((n << b) | (n >> (32 - b))) & 0xffffffff

def sub(b):
b = long_to_bytes(b)
return bytes([sbox[i] for i in b])

def phash(block, key1, key2):
block = md5(block.encode()).digest()
block = 4 * block
blocks = [bytes_to_long(block[i:i+4]) for i in range(0, len(block), 4)]

m = 0xffffffff
rv1, rv2 = 0x2423380b4d045, 0x3b30fa7ccaa83
x, y, z, u = key1, 0x39ef52e9f30b3, 0x253ea615d0215, 0x2cd1372d21d77

for i in range(13):
x, y = blocks[i] ^ x, blocks[i+1] ^ y
z, u = blocks[i+2] ^ z, blocks[i+3] ^ u
rv1 ^= (x := (x & m) * (m + (y >> 16)) ^ rotl(z, 3))
rv2 ^= (y := (y & m) * (m + (z >> 16)) ^ rotl(x, 3))
rv1, rv2 = rv2, rv1
rv1 = sub(rv1)
rv1 = bytes_to_long(rv1)

h = rv1 + 0x6276137d7 & m
key2 = sub(key2)

for i, d in enumerate(key2):
a = (h << 1) & m
b = (h << 3) & m
c = (h >> 4) & m
h ^= (a + b + c - d)
h += h
h &= m

h *= u * z
h &= m

return h


Few things to note here -

1. rotl is 32-bit rotate left
2. sbox is AES sbox, so that we dont try linear/differential cryptanalysis XD
3. Every operation in phash can be roughly thought on working on 32 bit uints since each operation is preceded by &m (0xffffffff) which makes everything operate mod $2^{32}$
4. Which means rv1, rv2, x, y, z, u are all 32bit values including our keys, i.e rv1 = 0x2423380b4d045 & m = 0x80b4d045
5. Insted of a block, md5(block) is hashed, so we have little to no control over the message and we have to actually expoit the keys.

Finding relevant key1 and key2 seems difficult by bare logic, dont worry
since the first block provides a subtle hint

"You don't have to add the z3 solver to your firmware ever again. Now you can use it forever.

We can make an SMT model in z3 and let it do its wonders!
But first let us demarcate the function so it’s easier to refer

##### function setup
def phash(block, key1, key2):
block = md5(block.encode()).digest()
block = 4 * block
blocks = [bytes_to_long(block[i:i+4]) for i in range(0, len(block), 4)]

m = 0xffffffff
rv1, rv2 = 0x2423380b4d045, 0x3b30fa7ccaa83
x, y, z, u = key1, 0x39ef52e9f30b3, 0x253ea615d0215, 0x2cd1372d21d77

##### key1 loop
    for i in range(13):
x, y = blocks[i] ^ x, blocks[i+1] ^ y
z, u = blocks[i+2] ^ z, blocks[i+3] ^ u
rv1 ^= (x := (x & m) * (m + (y >> 16)) ^ rotl(z, 3))
rv2 ^= (y := (y & m) * (m + (z >> 16)) ^ rotl(x, 3))
rv1, rv2 = rv2, rv1
rv1 = sub(rv1)
rv1 = bytes_to_long(rv1)

##### key2 substitution
    h = rv1 + 0x6276137d7 & m
key2 = sub(key2)

##### key2 loop
    for i, d in enumerate(key2):
a = (h << 1) & m
b = (h << 3) & m
c = (h >> 4) & m
h ^= (a + b + c - d)
h += h
h &= m

##### final multiplication
    h *= u * z
h &= m

return h


## Enter Z3

Since we are dealing with 32 bits entities only, we will use theory of
bitvectors. Where each variable is simply considered a collection of
bits and all the operations are treated as symbolic computation upon
those sets of bits. Pretty much like a hardware circuit, where each
component is say a 32 bit register.

### Representing function setup

block = md5(message).digest()
block = 4*block
blocks = [int.from_bytes(block[i:i+4],'big') for i in range(0, len(block), 4)]
# we will let the blocks be the way they are in the real function
# or we could declare them as 32-bit constants, either suffices
# blocks = [BitVecVal(i,32) for i in blocks] is treated same as above
rv1, rv2 = BitVecVal(0x2423380b4d045,32), BitVecVal(0x3b30fa7ccaa83,32)
# note that they will automatically be truncated to 32 bits
key1 = BitVec('key1', 32)
key2 = BitVec('key2', 32)
# key1 and key2 treated as 32-bit unknowns
m = 0xffffffff #can be written as -1 as well ;)
x, y, z, u = key1, *[BitVecVal(i,32) for i in (0x39ef52e9f30b3, 0x253ea615d0215, 0x2cd1372d21d77)]
# bitvecs can be used almost like python variables! I love z3 API


### Representing key1 loop

for i in range(13):
x,y = blocks[i] ^ x, blocks[i+1] ^ y
# easy interop with xor
z,u = blocks[i+2] ^ z, blocks[i+3] ^ u
x,y,z,u = [simplify(i) for i in (x,y,z,u)]
# simplify tries to evaluate the current symbolic computation of a variable
# and tries to simplify as much as possible (no effect on truth, can skip)
x = x*(m + LShR(y,16)) ^ RotateLeft(z, 3)
# expanding the walrus (:=)
# note that >> is replaced with LShR, this is because in theory of bitvecs
# there are two kinds of shift rights, arithmetic and logical. logical
# shift right shifts as is whereas arithmetic shift right also retains the
# original sign. Python ints are infinite, so >> means logical shift by
# shift by default but in z3 >> is arithmetic while LShR is logical
rv1 ^= x
y = y*(m + LShR(z,16)) ^ RotateLeft(x, 3)
rv2 ^= y
rv1, rv2 = rv2, rv1
rv1 = sub(rv1)


Wait, how will sub work?
Good question, it wont the sub is a previously defined python function which
expects python int to index the SBOX and return a value. It wont
understand BitVec as index and so wont our model understand our function!

### Theory of Arrays

SMT solvers are so mature, we can use multiple theories to create and solve a
model!
With theory of arrays, we can essentially declare an array with arbitrary index
and arbitrary stored value.

SBOX = Array('SBOX', BitVecSort(8), BitVecSort(8))
def sub(bitvec32):
vec_bytes = [Extract(8*i+7, 8*i, bitvec32) for i in range(3,-1,-1)]
# logical analogue of 32-bit int to 4-bytes in big-endian order
# Extract(hi,lo,bitvec) takes the bits [lo,hi) and creates a new
# bitvector of size hi-lo+1
return Concat([SBOX[i] for i in vec_bytes])
# i is index BitVec(8) to Array SBOX and SBOX[i] is BitVec(8) stored
# Concat concatanates the 4 8-bitvectors to an 32-bitvector like it
# is done in after the original sub in the key1 loop


### Representing key2 substitution

h = simplify(rv1 + 0x6276137d7)
subkey2 = [Extract(8*i+7,8*i,key2) for i in range(3,-1,-1)]
# splitting to 8-bits again
subkey2 = [SBOX[i] for i in subkey2]
# note that we need to mention SBOX only once in the whole logic
subkey2 = [ZeroExt(24,i) for i in subkey2]


What is ZeroExt? Note that subkey2 after substitution is a list of 8-bit
entities. Now this one wouldn’t look much severe to a programmer since all
would give type warning but just add the smaller value to a bigger value
without ranting. whereas python doesn’t bother at all. But if we think like
a hardware, you will be bothered when presented to add a 32-bit register to
a 8-bit register. Since we require this value later, we make it a 32 bit value
by Extending with 24 zeros in the front (if we were not dealing with uint32
we would have sign-extended these 8-bit values.

### Representing key2 loop

for i,d in enumerate(subkey2):
a = (h<<1)
b = (h<<3)
c = LShR(h,4)
# note the LShR again, blindly missing an operator can cost you hours :)
h ^= (a+b+c-d)
h += h
h ^= u*z


Now after all this bizarre symbolic computation, we are not done yet, we are
not here just to model but to ask the solver to find the values of key1 and
key2 such that this symbolic function evaluates to 0

### Calling a solver

solver = Solver()
solver.add(h==0) #the final h we have here should be 0
for i,v in enumerate(sbox): # the original AES sbox
# specifing the exact substitution box
if solver.check() == sat:
m = solver.model()
# a desirable model
return (m[key1].as_long(), m[key2].as_long())
# as_long converts bitvecs to python ints


### Putting it all together

def hack_proof_of_work(message):
block = md5(message).digest()
block = 4*block
blocks = [int.from_bytes(block[i:i+4],'big') for i in range(0, len(block), 4)]
rv1, rv2 = BitVecVal(0x2423380b4d045,32), BitVecVal(0x3b30fa7ccaa83,32)
key1 = BitVec('key1', 32)
key2 = BitVec('key2', 32)
m = 0xffffffff
x, y, z, u = key1, *[BitVecVal(i,32) for i in (0x39ef52e9f30b3, 0x253ea615d0215, 0x2cd1372d21d77)]
for i in range(13):
x,y = blocks[i] ^ x, blocks[i+1] ^ y
z,u = blocks[i+2] ^ z, blocks[i+3] ^ u
x,y,z,u = [simplify(i) for i in (x,y,z,u)]
x = x*(m + LShR(y,16)) ^ RotateLeft(z, 3)
rv1 ^= x
y = y*(m + LShR(z,16)) ^ RotateLeft(x, 3)
rv2 ^= y
rv1, rv2 = rv2, rv1
rv1 = sub(rv1)

SBOX = Array('SBOX', BitVecSort(8), BitVecSort(8))
def sub(bitvec32):
vec_bytes = [Extract(8*i+7, 8*i, bitvec32) for i in range(3,-1,-1)]
return Concat([SBOX[i] for i in vec_bytes])

h = simplify(rv1 + 0x6276137d7)
subkey2 = [Extract(8*i+7,8*i,key2) for i in range(3,-1,-1)]
subkey2 = [SBOX[i] for i in subkey2]
subkey2 = [ZeroExt(24,i) for i in subkey2]

for i,d in enumerate(subkey2):
a = (h<<1)
b = (h<<3)
c = LShR(h,4)
h ^= (a+b+c-d)
h += h
h ^= u*z
solver = Solver()
for i,v in enumerate(sbox):
if solver.check() == sat:
m = solver.model()
return (m[key1].as_long(), m[key2].as_long())


Lets go!

message_one = "You don't have to add the z3 solver to your firmware ever \
again. Now you can use it forever."
key1, key2 = hack_proof_of_work(message_one) Well, no key yet?
I know, lets discuss a few problems and workarounds

### Too complicated model

1. Too many multiplications. There are 13 loops and a lot of multiplications. And as one may know, factoring has never been easy.
2. We dont even have a tentative time by which the solver will spew a satisfying model. This is the general drawback of SMT/SAT solvers.
3. Not breaking the problem as (an actually intelligent) human

So lets analyze the problem carefully part by part.

## Re-analysis

1. The final value is h(final) = h(part 2)*u*z (part 1) and we need it to be 0 final h will be 0 if sum of least significant 0 of h, u and z exceeds
2. as overflows are ignored in 32-bit multiplication.

3. What if we can get h of key2 loop to 0 by its own?

### Reversing only key2 loop

def hack_only_key2(h,nbits=0):
# note its post substitution for less complexity and speed
# nbits is the number of nonzero most significant bits we can tolerate
h = BitVecVal(h,32)
key2 = BitVec('key2',32)
subkey2 = [Extract(8*i+7,8*i,key2) for i in range(3,-1,-1)]
subkey2 = [ZeroExt(24,i) for i in subkey2]
for i,d in enumerate(subkey2):
a = (h<<1)
b = (h<<3)
c = LShR(h,4)
h ^= (a+b+c-d)
h += h
solver = Solver()
if solver.check() == sat:
m = solver.model()
return m[key2].as_long()

print(try_only_key2(1337,0))
# None
print(try_only_key2(1337,1))
# None
print(try_only_key2(1337,2))
# None
print(try_only_key2(1337,4))
# None
print(try_only_key2(1337,8))
# 1311637496
print(try_only_key2(1,0))
# 75586596
print(try_only_key2(2,0))
# 276293996
print(try_only_key2(3,0))
# 283836416


It seems to be working but only for a limited number of values, lets see how frequently can it work independent of h from first loop.

from tqdm import tqdm
from random import randint
num_samples = 4096
validkey2 = [try_only_key2(randint(0,2**32,0)) for i in tqdm(range(num_samples))]
print("Number of suitable h", num_samples-validkey2.count(None))
# 5
validkey2 = [try_only_key2(randint(0,2**32,1)) for i in tqdm(range(num_samples))]
print("Number of suitable h", num_samples-validkey2.count(None))
# 11
validkey2 = [try_only_key2(randint(0,2**32,4)) for i in tqdm(range(num_samples))]
print("Number of suitable h", num_samples-validkey2.count(None))
# 72


It appears that if we entirely ignore key1, and let h be whatever it
desires to be i.e. random, we can have our luck with finding key2 with
roughly 1 in 400 chance (ignoring the zeros for u*z entirely)

So we can bruteforce for key1, try solving for key2 and this should
take a couple of seconds and lo we are done.

## Solve Script

def zerocount(num):
count = 0
while num&1==0:
count+=1
num>>=1
return count

def form_blocks(block):
"""making bruteforce faster by removing recomputation of md5"""
block = md5(block.encode()).digest()
block = 4 * block
blocks = [bytes_to_long(block[i:i+4]) for i in range(0, len(block), 4)]
return blocks

def phashk1(blocks, key1):
"""hash state till key1 part and before key2 substitution"""
m = 0xffffffff
rv1, rv2 = 0x2423380b4d045, 0x3b30fa7ccaa83
x, y, z, u = key1, 0x39ef52e9f30b3, 0x253ea615d0215, 0x2cd1372d21d77

for i in range(13):
x, y = blocks[i] ^ x, blocks[i+1] ^ y
z, u = blocks[i+2] ^ z, blocks[i+3] ^ u
rv1 ^= (x := (x & m) * (m + (y >> 16)) ^ rotl(z, 3))
rv2 ^= (y := (y & m) * (m + (z >> 16)) ^ rotl(x, 3))
rv1, rv2 = rv2, rv1
rv1 = sub(rv1)
rv1 = bytes_to_long(rv1)
h = rv1 + 0x6276137d7 & m
# also return the number of zeros in u*z so our model finds it easier
return h,zerocount(u*z)

def desubstitute(key2):
"""reverse the substitution on the found key2"""
# we could have added the substitution to the model too, but since we
# are bruteforcing, we appreciate a bit of extra speed
return bytes([sbox.index(i) for i in int.to_bytes(key2,4,'big')])

def bruteforce_key1(block):
blocks = form_blocks(block)
for key1 in tqdm(range(2**32),total=-1):
h, nbits = phashk1(blocks, key1)
key2 = try_only_key2(h, nbits)
if key2:
return key1, desubstitute(key2)


### Final Test

message_one = "You don't have to add the z3 solver to your firmware ever \
again. Now you can use it forever."
key1, key2 = bruteforce_key1(message_one)
print(f"{key1=} {key2=}")
assert phash(message_one, key1, key2) == 0
# 909it [00:16, 54.92it/s]
# key1=909 key2=3711505522


## Post solve wanderings

I solved the challenge manually by prompting bruteforce 4 times. I wanted to create a netcat script, but couldn’t as Hack The Box terminated all instances
post the CTFs so I cant access the server.

I wonder why is there an uncanny resemblance between the hash function and a hash collision challenge I created last year for zh3r0 CTF v2

text = [int.from_bytes(text[i:i+4],'big') for i in range(0,len(text),4)]
M = 0xffff
x,y,z,u = 0x0124fdce, 0x89ab57ea, 0xba89370a, 0xfedc45ef
A,B,C,D = 0x401ab257, 0xb7cd34e1, 0x76b3a27c, 0xf13c3adf
RV1,RV2,RV3,RV4 = 0xe12f23cd, 0xc5ab6789, 0xf1234567, 0x9a8bc7ef
for i in range(0,len(text),4):
X,Y,Z,U = text[i]^x,text[i+1]^y,text[i+2]^z,text[i+3]^u
RV1 ^= (x := (X&0xffff)*(M - (Y>>16)) ^ ROTL(Z,1) ^ ROTR(U,1) ^ A)
RV2 ^= (y := (Y&0xffff)*(M - (Z>>16)) ^ ROTL(U,2) ^ ROTR(X,2) ^ B)
RV3 ^= (z := (Z&0xffff)*(M - (U>>16)) ^ ROTL(X,3) ^ ROTR(Y,3) ^ C)
RV4 ^= (u := (U&0xffff)*(M - (X>>16)) ^ ROTL(Y,4) ^ ROTR(Z,4) ^ D)


Anyways, it was a fun challenge, I had a lot of fun and hope that some weird soul had its fun too reading this writeup :)

]]>
deuteriumfarziemailid69@gmail.com
SDCTF 2022 Crypto - Tasty Crypto Roll2022-05-10T00:00:00+05:302022-05-10T00:00:00+05:30https://deut-erium.github.io/WriteUps/2022/sdctf/crypto/tasty_crypto_roll/SDCTF-2022-Tasty-Crypto-Roll

# Tasty Crypto Roll

## Description

CRYPTO - Hard
Tasty Crypto Roll
Bob, the genius intern at our company, invented AES-improved. It is based on AES but with layers after layers of proprietary encryption techniques on top of it.

The end result is an encryption scheme that achieves both confusion and diffusion. The more layers of crypto you add, the better the security, right?

Encrypter
encrypt.py
Encrypted file
enc.bin
Note
The intended solution requires very little brute force and runs under 5 seconds on our machine.
By k3v1n

## Source

import os
import random
import secrets
import sys
from Crypto.Cipher import AES

ENCODING = 'utf-8'

def generate_key():
return os.getpid(), secrets.token_bytes(16)

def to_binary(b: bytes):
return ''.join(['{:08b}'.format(c) for c in b])

def from_binary(s: str):
return bytes(int(s[i:i+8], 2) for i in range(0, len(s), 8))

def encrypt(key: bytes, message: bytes):
cipher = AES.new(key, AES.MODE_ECB)
return cipher.encrypt(message)

key1, key2 = generate_key()

print(f'Using Key:\n{key1}:{key2.hex()}')

def get_flag():
flag = input('Enter the flag to encrypt: ')
if not flag.startswith('sdctf{') or not flag.endswith('}') or not flag.isascii():
print(f'{flag} is not a valid flag for this challenge')
sys.exit(1)
return flag

plaintext = get_flag()[6:-1]

data = plaintext.encode(ENCODING)

codes = list(''.join(chr(i) * 2 for i in range(0xb0, 0x1b0)))
random.seed(key1)
random.shuffle(codes)
sboxes = [''.join(codes[i*4:(i+1)*4]) for i in range(128)]

if len(set(sboxes)) < 128:
sys.exit(1)

data = ''.join(sboxes[c] for c in data).encode(ENCODING)
data = encrypt(key2, to_binary(data).encode(ENCODING))

random.seed(key1)
key_final = bytes(random.randrange(256) for _ in range(16))

data_bits = list(to_binary(data))
random.shuffle(data_bits)
data = from_binary(''.join(data_bits))

ciphertext = encrypt(key_final, data)

print(f'Encrypted: {ciphertext.hex()}')
with open('enc.bin2', 'wb') as ef:
ef.write(ciphertext)


## Analysis

Here we can see mainly two parts

1. There are two keys
• key1: pid of current process
• key2: secure random key of 16 bytes
2. key1 is used as seed at a lot of places and is bruteforcable (< 2^15) key_final and sboxes are derived from key1, shuffling is done using key1

### Steps to crack

1. decrypt using key_final
2. convert the intermediate ciphertext to_binary
3. de-shuffle the bits
4. generate from_binary intermediate ciphertext of the deshuffled bits
5. decrypt using key2???

### How to find key1?

Assume you have the correct key1, reverse for the key, validate the results
using some validator/logical assumption.

codes is a list of 2*(0x1b0-0xb0) = 512 characters, utf-8 encoding of which is 2-bytes each
sboxes will have 4char strings, which encode to 8 bytes each on utf-8 (i.e after substitution)
data is now 4*2 = 8 times each byte of the original plaintext
data is converted to_binary before encryption hence each byte is converted
to 8 b"0" or b"1" byte. Hence each character is substituted to some
8*8 = 64 byte string before encryption.
Hence len of flag = len(ciphertext)//64 = 3520//64 = 55 bytes

#### Assumption 1

Since length of flag is 55 characters, would it be reasonable to assume that
there would be repeatitions of characters. And since each flag character is
substituted to fixed 64-byte strings before encryption which is a multiple
of AES block size of 16, AES also acts like simple substitution of the flag
but we do not know the mapping.
Hence if we reverse till step 4 above, we can simply check if there are any
repeating 64-byte blocks, as incorrect shuffling of bits will result in each block to be distinct with almost 1 probability.

with open('enc.bin', 'rb') as f:

def to_binary(b: bytes):
return ''.join(['{:08b}'.format(c) for c in b])

def from_binary(s: str):
return bytes(int(s[i:i+8], 2) for i in range(0, len(s), 8))

def encrypt(key, message):
return AES.new(key, AES.MODE_ECB).encrypt(message)

def decrypt(key: bytes, message: bytes):
return AES.new(key, AES.MODE_ECB).decrypt(message)

def unshuffle(data_list, shuffle_order):
res = [None]*len(data_list)
for i,v in enumerate(shuffle_order):
res[v] = data_list[i]
return res

def key_final_dec(key1, ciphertext):
random.seed(key1)
key_final = bytes(random.randrange(256) for _ in range(16))

data = decrypt(key_final, ciphertext)
data_bits = list(to_binary(data))
data_bits_order = list(range(len(data_bits)))
random.shuffle(data_bits_order)
data_bits_uns = unshuffle(data_bits, data_bits_order)
data = from_binary(''.join(data_bits_uns))
return data


Lets add a few validation too

def key_final_enc(key1, data):
random.seed(key1)
key_final = bytes(random.randrange(256) for _ in range(16))
data_bits = list(to_binary(data))
random.shuffle(data_bits)
data = from_binary(''.join(data_bits))
return encrypt(key_final, data)

def test_unshuffle():
random_text = list(random.randbytes(16*1337))
random_text_shuffled = random_text.copy()
shuffle_order = list(range(len(random_text)))
random.seed(1337)
random.shuffle(random_text_shuffled)
random.seed(1337)
random.shuffle(shuffle_order)
assert unshuffle(random_text_shuffled, shuffle_order) == random_text

def test_key_final_dec():
random_text = random.randbytes(16*100)
assert key_final_dec(1337, key_final_enc(1337, random_text)) == random_text

test_unshuffle()
test_key_final_dec()


Looks like all the decryption functions are correct, lets proceed with
bruteforcing for key1

for key1 in tqdm(range(2**15),desc='solving for key1'):
data = key_final_dec(key1, ciphertext)
substitutions = Counter(data[i:i+64] for i in range(0,len(data),64))
if len(substitutions)!=len(data)//64:
print("pid =",key1)
break


After waiting for an eternity, and exhausting the search space of possible pid’s
yet not getting any key1 got me confused. I checked my script locally for a
test flag it seemed to work fine. There could only be one possibility
the flag contains 55 distinct characters
But how would I find key1 now?

#### Missed Catch

@Utaha#6878 pointed out, that since there are only 256 distinct values in codes each repeated twice, and each character encoded to some b"0" or b"1"
byte strings of length 16, It must be encrypted to the same block always.
Since the flag is 55*4 = 220 such 16-byte codes and each code is used twice
for most of the characters, there will be repating 16-byte blocks even with
distinct flag characters.

#### Assumption 2

for key1 in tqdm(range(2**15),desc='solving for key1'):
data = key_final_dec(key1, ciphertext)
substitutions = Counter(data[i:i+16] for i in range(0,len(data),16))
if len(substitutions)!=len(data)//16:
print("pid =",key1)
break


pid = 83

And we found our key1!
And we can confirm that the flag is indeed 55 distinct characters.

Wait, if the flag is 55 distinct characters, how will we solve for the subs?
We have no statistical advantage and hence bye bye Mr quipquip

### How do we find mapping for substitution?

Each sbox entry is composed of 4 2-byte strings, which can be one of 256
possible values. Moreover, their order is fixed, which is determined by key1.

If we try to solve for all valid mappings for AES(binary(sbox(char))) we will probably end up on the correct mapping and get our flag.

+---------------+---------------+------------------------+---------------+
|flag0          |    flag1      |                        |   flag55      |
+---------------+---------------+         ....           +---------------+
|  sbox         |   sbox        |                        |    sbox       |
+---+---+---+---+---+---+---+---+------------------------+---------------+
|c1 |c2 |c3 |c4 |c5 |c6 |c7 |c8 |                        |               |
|   |   |   |   |   |   |   |   |                        |               |
+---+---+---+---+---+---+---+---+         ....           +---------------+
|   AES         |    AES        |                        |               |
+---+---+-------+---------------+------------------------+---------------+
|   |   +------+
|   +--+       |
+------+-------+-------+------+
|E(c1) | E(c2) | E(c3) | E(c4)|
+------+-------+-------+------+


### Enter Z3

We can assume our flag to be a list of BitVec of 7 bits each
And let the sboxes be a mapping from 7 bits to 64 bits each (16x4)
This can be achieved by assuming sbox to be an array which is indexed
by BitVec(7) and contains elements of BitVec(64)
And we assume AES to be some function form BitVec(16) to BitVec(128)

flag = [BitVec('flag_'+str(i),7) for i in range(len(data)//64)]
sboxmap = Array('sbox',BitVecSort(7), BitVecSort(64))
aes_encryption = Function('AES',BitVecSort(16), BitVecSort(128))

codes = list(''.join(chr(i) * 2 for i in range(0xb0, 0x1b0)))
random.seed(key1)
random.shuffle(codes)
# keeping sboxes utf encoded already
sboxes = [''.join(codes[i*4:(i+1)*4]).encode() for i in range(128)]

sbytes = b''.join(sboxes)
sboxints = list(map(lambda x:int.from_bytes(x,'big'),
set(sbytes[i:i+2] for i in range(0,len(sbytes),2))))
# integer values for 2-byte codes from sbox, will be explained shortly

sboxes = [int.from_bytes(i,'big') for i in sboxes]
data = key_final_dec(key1, ciphertext)
# converting intermediate decryption to 128 bit ints
data_int = []
for i in range(0,len(data),16):
data_int.append(int.from_bytes(data[i:i+16],'big'))

# we know the sbox already
constraints = [sboxmap[i]==sboxes[i] for i in range(128)]
for i in range(len(data)//64):
four_code = sboxmap[flag[i]]
# splitting 64 bit quantity to 16 bit individual sbox codes
four_code_parts = [Extract(16*i+15,16*i,four_code) for i in range(3,-1,-1)]
# for each code, matching aes_encryption with the observed value
for a,b in zip(data_int[4*i:4*i+4], four_code_parts):
constraints.append(aes_encryption(b)==a)
# last but not least, aes_encryption(i) is unique for each plaintext
# how would z3 know? Distinct function encodes them appropriately to
# be distinct
constraints.append(Distinct([aes_encryption(i) for i in sboxints]))

solver = Solver()
for m in all_smt(solver, flag):
# lets check for all satisfying flags (in case there are more than one
# possible mappings and we will rule out invalid ones in that scenario?
flag_bytes = bytes([m.eval(flag[i]).as_long() for i in range(len(flag))])
assert len(set(flag_bytes)) == len(Counter(data[i:i+64] for i in range(0,len(data),64)))
print(flag_bytes)


## Flag

After running the script, we finally get our flag!

b'r0l1-uR~pWn.c6yPtO_wi7h,ECB:I5*b8d!KQvJmLxgX9DsaANMFSeU'

And it turns out to be the only satisfying assignment.
Turns out if there were repeated characters in the flag, we will get multiple
possible satisfying values. So the admins have not been so cheeky afterall

## Full script

Note that it takes a couple of seconds to find the z3 model

import random
from Crypto.Cipher import AES
from collections import Counter
from tqdm import tqdm
from z3 import *
import sys

def all_smt(s, initial_terms):
def block_term(s, m, t):

def fix_term(s, m, t):

def all_smt_rec(terms):
if sat == s.check():
m = s.model()
yield m
for i in range(len(terms)):
s.push()
block_term(s, m, terms[i])
for j in range(i):
fix_term(s, m, terms[j])
yield from all_smt_rec(terms[i:])
s.pop()
yield from all_smt_rec(list(initial_terms))

with open('enc.bin', 'rb') as f:

def to_binary(b: bytes):
return ''.join(['{:08b}'.format(c) for c in b])

def from_binary(s: str):
return bytes(int(s[i:i + 8], 2) for i in range(0, len(s), 8))

def encrypt(key, message):
return AES.new(key, AES.MODE_ECB).encrypt(message)

def decrypt(key: bytes, message: bytes):
return AES.new(key, AES.MODE_ECB).decrypt(message)

def key_final_enc(key1, data):
random.seed(key1)
key_final = bytes(random.randrange(256) for _ in range(16))
data_bits = list(to_binary(data))
random.shuffle(data_bits)
data = from_binary(''.join(data_bits))
return encrypt(key_final, data)

def unshuffle(data_list, shuffle_order):
res = [None] * len(data_list)
for i, v in enumerate(shuffle_order):
res[v] = data_list[i]
return res

def test_unshuffle():
random_text = list(random.randbytes(16 * 100))
random_text_shuffled = random_text.copy()
shuffle_order = list(range(len(random_text)))
random.seed(10)
random.shuffle(random_text_shuffled)
random.seed(10)
random.shuffle(shuffle_order)
assert unshuffle(random_text_shuffled, shuffle_order) == random_text

test_unshuffle()

def key_final_dec(key1, ciphertext):
random.seed(key1)
key_final = bytes(random.randrange(256) for _ in range(16))

data = decrypt(key_final, ciphertext)
data_bits = list(to_binary(data))
data_bits_order = list(range(len(data_bits)))
random.shuffle(data_bits_order)
data_bits_uns = unshuffle(data_bits, data_bits_order)
data = from_binary(''.join(data_bits_uns))
return data

def test_key_final_dec():
random_text = random.randbytes(16 * 100)
assert key_final_dec(10, key_final_enc(10, random_text)) == random_text

test_key_final_dec()

for key1 in tqdm(range(2**15), desc='solving for key1'):
data = key_final_dec(key1, ciphertext)
substitutions = Counter(data[i:i + 16] for i in range(0, len(data), 16))
if len(substitutions) != len(data) // 16:
print("pid =", key1)
break

codes = list(''.join(chr(i) * 2 for i in range(0xb0, 0x1b0)))
random.seed(key1)
random.shuffle(codes)
sboxes = [''.join(codes[i * 4:(i + 1) * 4]).encode() for i in range(128)]
sbytes = b''.join(sboxes)
sboxints = list(map(lambda x: int.from_bytes(x, 'big'), set(
sbytes[i:i + 2] for i in range(0, len(sbytes), 2))))
sboxes = [int.from_bytes(i, 'big') for i in sboxes]
data = key_final_dec(key1, ciphertext)
data_int = []
for i in range(0, len(data), 16):
data_int.append(int.from_bytes(data[i:i + 16], 'big'))

flag = [BitVec('flag_' + str(i), 7) for i in range(len(data) // 64)]
sboxmap = Array('sbox', BitVecSort(7), BitVecSort(64))
aes_encryption = Function('AES', BitVecSort(16), BitVecSort(128))

constraints = [sboxmap[i] == sboxes[i] for i in range(128)]
for i in range(len(data) // 64):
four_code = sboxmap[flag[i]]
four_code_parts = [Extract(16 * i + 15, 16 * i, four_code)
for i in range(3, -1, -1)]
for a, b in zip(data_int[4 * i:4 * i + 4], four_code_parts):
constraints.append(aes_encryption(b) == a)
constraints.append(Distinct([aes_encryption(i) for i in sboxints]))
solver = Solver()
# if solver.check() == sat:
# m = solver.model()
for m in all_smt(solver, flag):
flag_bytes = bytes([m.eval(flag[i]).as_long() for i in range(len(flag))])
assert len(set(flag_bytes)) == len(
Counter(data[i:i + 64] for i in range(0, len(data), 64)))
print(flag_bytes)
else:
print("failed to solve")


### Alternate Solution by teammate (Utaha#6878)

All due regards to him for solving the challenge while I was stuck over finding
key1 XD

All parts will be almost same except the substitution solving part, which he
did by manual bruteforcing i.e. recursively enumerating all mappings and backtracking on contradictions

mp = dict()
codes = sum([[i, i] for i in range(256)], start=[])
# notice that the range is changed from [0xb0, 0x1b0) to [0, 256).
# It's just for relabeling.
random.seed(key1)
random.shuffle(codes)
sboxes = [codes[i*4:(i+1)*4] for i in range(128)]

def match(a, b):
"""
equate two objects elementwise ignoring if the entry is -1
"""
for x, y in zip(a, b):
if x == -1 or y == -1:
continue
if x != y:
return False
return True

def getFlag(cip, sboxes, mp):
# get the flag based on current mapping, unknown char will be shown as '?'
res = []
for c in cip:
afterMap = [mp.get(x, -1) for x in c]
found = False
for i, s in enumerate(sboxes):
if s == afterMap:
res.append(i)
found = True
break
res.append(ord('?'))
return bytes(res)

def brute(cip, sboxes, mp):
"""
cip and sboxes remain unchanged throughout the recursive call,
but I feel bad using global varaibles.
"""
if DEBUG:
print(getFlag(cip, sboxes, mp))

# check is finished
isFinished = True
for c in cip:
if all(x in mp for x in c):
pass
else:
isFinished = False

if isFinished:
return

# try matching
mp = mp.copy()

# Find the one with least possible matches.
min_pos = 256
index = -1

for idx, c in enumerate(cip):
afterMap = [mp.get(x, -1) for x in c]
if -1 not in afterMap:
continue

matches = [s for s in sboxes if match(s, afterMap)]

if len(matches) == 0:
break

if min_pos > len(matches):
index = idx
min_pos = len(matches)

return

# now bruteforce all possibilities
assert index != -1
afterMap = [mp.get(x, -1) for x in cip[index]]
matches = [s for s in sboxes if match(s, afterMap)]
for m in matches:
for x, y in zip(cip[index], m):
mp[x] = y
brute(cip, sboxes, mp)

# This is based on the repetition
for _ in [132, 197]:
mp = {35: 224, 109: 144, 4: _}
brute(cip, sboxes, mp)

print(b"sdctf{" + x + b"}")

# The fourth one is the actual answer

Ciphertext repetition:
[4, 5, 4, 6]
[34, 35, 36, 35]
[109, 60, 110, 109]
Sbox repetition:
[132, 93, 132, 211]
[197, 32, 197, 248]
[144, 86, 67, 144]
[165, 224, 27, 224]
b'sdctf{r0l1-LR~pWn.c6yPtO_wi7h,ECB:I5*b8d!KQvJmLxgX95saANMFSeU}'
b'sdctf{r0l1-uR~pWn.c6yPtO_wi7h,ECB:I5*b8d!cQvJmLxgX9DsaANMFSeU}'
b'sdctf{r0l1-uR~pWn.c6yPtO_wi7h,ECB:I5*b8d!KQvJmLxgX9DsaANMFSeU}'
b'sdctf{r0l1-uR~pWn.c6yPtO_wi7h,ECB:I5*b8d!cQvJmLxgX95saANMFSeU}'
b'sdctf{r0l1-LR~pWn.c6yPtO_wi7h,ECB:I5*b8d!KQvJmLxgX9DsaANMFSeU}'
b'sdctf{r0l1-LR~pWn.c6yPtO_wi7h,ECB:I5*b8d!cQvJmLxgX9DsaANMFSeU}'
b'sdctf{r0l1-uR~pWn.c6yPtO_wi7h,ECB:I5*b8d!KQvJmLxgX95saANMFSeU}'
b'sdctf{r0l1-LR~pWn.c6yPtO_wi7h,ECB:I5*b8d!cQvJmLxgX95saANMFSeU}'


full script in solve2.py

]]>
deuteriumfarziemailid69@gmail.com

## Challenge Description

Yet another oracle, but the queries are costly and limited so be frugal with them. pythia.2021.ctfcompetition.com 1337

source.zip

## Server

max_queries = 150
query_delay = 0

for _ in range(3)), 'UTF-8') for _ in range(3)]

print("What you wanna do?")
print("1- Set key")
print("3- Decrypt text")
print("4- Exit")
try:
return int(input(">>> "))
except:
return -1

print("Welcome!\n")

key_used = 0

for query in range(max_queries):

if option == 1:
print("Which key you want to use [0-2]?")
try:
i = int(input(">>> "))
except:
i = -1
if i >= 0 and i <= 2:
key_used = i
else:
elif option == 2:
passwd = bytes(input(">>> "), 'UTF-8')

print("Checking...")
# Prevent bruteforce attacks...
time.sleep(query_delay)
print("ACCESS GRANTED: " + flag.decode('UTF-8'))
else:
elif option == 3:

ct = input(">>> ")
print("Decrypting...")
# Prevent bruteforce attacks...
time.sleep(query_delay)
try:
nonce, ciphertext = ct.split(",")
nonce = b64decode(nonce)
ciphertext = b64decode(ciphertext)
except:
print("ERROR: Ciphertext has invalid format. \
Must be of the form \"nonce,ciphertext\", where \
nonce and ciphertext are base64 strings.")
continue

kdf = Scrypt(salt=b'', length=16, n=2**4, r=8, p=1, backend=default_backend())
try:
cipher = AESGCM(key)
plaintext = cipher.decrypt(nonce, ciphertext, associated_data=None)
except:
print("ERROR: Decryption failed. Key was not correct.")
continue

print("Decryption successful")
elif option == 4:
print("Bye!")
break
else:
print("Invalid option!")
print("You have " + str(max_queries - query) + " trials left...\n")


## Recon

Taking a look at the source, we have a few observations

• The server generates 3 passwords of 3 lower case ASCII each and uses Scrypt (a Password based key derivation function) on it to derive a 16 byte encryption key from each of the 3 byte passwords, which can be treated as deriving 3 16-byte keys from a set of $26^3 = 17576$ known randomly generated keys.
• It provides us 3 options to work with. Option 1 to set 1 of the 3 unknown randomly generated keys
• Option 3 provides as a decryption oracle, allowing us to check whether any arbitrary ciphertext of our choice decrypts successfully. (Why would it fail? more details in How decryption works)
• Option 2 is the option we dig, give the server all three passwords (hence keys) correctly, it gives back the flag.
• There are 150 queries for all 3 options, which means we have effectively 147 decryption oracles to work with, which indicates we need an average case key recovery in <49 queries.

## Thinking Methodology/ Ideas to reject

### 1. Bruteforcing/ luck

Since the number of keys is quite small, one might be tempted to bruteforce the keys and be optimistic that he/she gets all three keys in 150 attempts.
But the fact that guessing 3 keys consecutively within 150 attempts has probability as low as $\approx 10^{-7}$ which is already out of practical server-bruteforcing further enforced by 10 s delay between each guess taking 25 mins for each bruteforce, its clearly crossed out.

### 2. The challenge is not about Scrypt

Again, one would be tempted to think that it could be some weakness of Scrypt or the given configuration, or some relation between the derived keys which renders GCM ez. This hypothesis can also countered easily looking at the amount of stuff going inside Scrypt :P

### 3. Famous attacks on AES-GCM

Again, someone sees AESGCM, they get cryptopals set 8 flashbacks.

1. Key recovery attacks on repeated nonces - Again this is possible if the server encrypts stuff, not check its decryption. What the attacker recovers is $E(0,K)$ not the key itself, but since keyspace is small, they could recover it through. Although again, this is definitely not the challenge.
2. Key recovery attacks on truncated mac - Clearly, I cant see any sort of truncation. So its out.

### Possible approach

The only information we can extract from the oracle is whether the provided ciphertext forms the given tag under the key of the server. How would this help us reduce the number of queries required from $26^3$ to say 50. If it was possible to get it in one shot, the challenge authors would not give a slack of 50 :P
How about there exists a (ciphertext, tag) which decrypts successfully for more than one key?
This would reduce the number of queries by half! We will just need to keep on asking ciphertexts for a 2 pair till the key of server happens to lie in that pair.
That’s all, if we can get a (ciphertext, tag) which is valid for $n$ keys, we can reduce the search to a binary search, requiring $log_{2}(26^3) \approx 14.1 = 15$ queries at max making 45 queries in total. Then again why 150 and not 50?
More on it later

## How decryption works

This little detour is for the people who may be confused about Authenticated Encryption with Associated Data, and why would an arbitrary decryption of some ciphertext would fail under a given key as opposed to some other mode of AES say CBC.
So the whole idea here is that the ciphertext will comprise of three parts

1. The encrypted payload i.e the data we wish to communicate
2. Associated un-encrypted data which contains any additional metadata which needs to be preserved against any sort of tempering.
3. Tag which is essentially an attempt of proof that the given ciphertext was encrypted by someone who holds the secret key and the payload and additional associated data has not been tempered with.

Whenever the server receives a ciphertext to decrypt, it will first try to verify that the received tag is actually corresponding to the received ciphertext encrypted with the secret key.
If the computation of tag on the ciphertext fails, the server would reject the proposal to decrypt the message.

### How AES-GCM works?

AES-GCM is, put simply, an authentication mechanism built upon AES in CTR mode (a stream cipher not necessary for our discussion), such that encrypted blocks of the ciphertext, additional authentication data, and lengths of those two put together in a GCM mac. This is whats all we will need to consider. Here comes the mandatory picture from Wikipedia. Just follow the components used in the Auth tag generation. GCM is simply a polynomial (in $GF(2^{128})$) constructed using the blocks of authentication data, ciphertext, and two additional blocks, one constructed using the lengths of data and ciphertext and one using the encryption of 96 bit nonce $N$ appended with 31 bits of 0 and a single bit 1. i.e. $s = E(N||0^{31}1,K)$
This polynomial is evaluated at $h = E(0,K)$ to compute the Auth tag
$$T = ((((((h*A_0) \oplus A_1)*h ... \oplus A_m)*h \oplus C_0)*h ... \oplus C_{n-1})*h \oplus L)*h \oplus s$$
Since there’s no additional data in the challenge, we get
$$T = ((((h*C_0) \oplus C_1)*h ... \oplus C_{n-1})*h \oplus L)*h \oplus s$$ or
$$T = C_0*h^{n+1} \oplus C_1*h^{n+1} ... \oplus C_{n-1}*h^{2} \oplus L*h \oplus s$$

## Attack

Continuing and exploring the idea above one would come across a recent paper titled Partitioning Oracle Attack and what’s cherry on the top is that a quick CTRL+F for github in the paper reveals the POC demo of the same making it a lot easier to implement.

### Construction

Continuing from the expression of tag, the terms dependent on key for calculation of tag are $\textbf{h, s}$ only.
$$T = C_0*\textbf{h}^{n+1} \oplus C_1*\textbf{h}^{n} ... \oplus C_{n-1}*\textbf{h}^{2} \oplus L*\textbf{h} \oplus \textbf{s}$$
$$C_0*\textbf{h}^{n+1} \oplus C_1*\textbf{h}^{n} ... \oplus C_{n-1}*\textbf{h}^{2} = T \oplus L*\textbf{h} \oplus \textbf{s}$$
$$C_0*\textbf{h}^{n-1} \oplus C_1*\textbf{h}^{n-2} ... \oplus C_{n-1} = (T \oplus L*\textbf{h} \oplus \textbf{s})*\textbf{h}^{-2}$$
writing $$(T \oplus L*\textbf{h} \oplus \textbf{s})*\textbf{h}^{-2}$$ as a key dependent quantity $$\textbf{B}$$ we can write it for $n$ keys $$K_0...K_{n-1}$$ , we get
$$C_0*\textbf{h}^{n-1}_{0} \oplus C_1*\textbf{h}^{n-2}_{0} ... \oplus C_{n-1} = \textbf{B}_{0}$$
$$C_0*\textbf{h}^{n-1}_{1} \oplus C_1*\textbf{h}^{n-2}_{1} ... \oplus C_{n-1} = \textbf{B}_{1}$$
$$\vdots \qquad \qquad \vdots \qquad \qquad \vdots \qquad \qquad \vdots$$
$$C_0*\textbf{h}^{n-1}_{n-1} \oplus C_1*\textbf{h}^{n-2}_{n-1} ... \oplus C_{n-1} = \textbf{B}_{n-1}$$
$$\begin{bmatrix} 1 & \textbf{h}_{0} & \textbf{h}_{0}^{2} & \cdots & \textbf{h}_{0}^{n-1}\\ 1 & \textbf{h}_{0} & \textbf{h}_{0}^{2} & \cdots & \textbf{h}_{0}^{n-1} \\ \vdots & \vdots &\vdots & \ddots & \vdots\\ 1 & \textbf{h}_{0} & \textbf{h}_{0}^{2} & \cdots & \textbf{h}_{0}^{n-1} \end{bmatrix} . \begin{bmatrix} C_{n-1} \\ C_{n-2} \\ \vdots \\ C_{0} \end{bmatrix} = \begin{bmatrix} \textbf{B}_{0} \\ \textbf{B}_{1} \\ \vdots \\ \textbf{B}_{n-1} \end{bmatrix}$$

Now that we have all the required equations set up, we can find $C_{0}, C_{1} \ldots, C_{n-1}$ through lagrange interpolation in $O(n^2)$ time and $O(n)$ space.

### Performance considerations

Theoretically 15 searches would be enough to find the key, but it would require a multicollision for ~8000 keys.
What we can do is to first check for a few groups of smaller sizes, then proceeding with binary search on a given group.
If we form groups of size $k$, the total number of calls should be roughly (for worst case number of calls) $17576/k + \lceil log_{2}k \rceil= 49 \implies k\approx 367$ Time taken to find a multicollision for $k=367$ keys,

import time
import statistics
k=367
times = []
for i in range(0,26**3,k):
start_time = time.time()
multi = multicollision(derived_keys[i:i+k])
times.append(time.time()-start_time)
print(statistics.mean(times))
#4.06


It takes around 4 seconds on an i9 processor. For the binary search part the times for computation take quadratically shorter duration, adding to roughly 1.5s for 9 calls.
Overall it should take average case $$(10+4)*(17576/367)*1/2 + 9*10 + 1.5 \approx 426s \approx 7mins$$ to find a key and worst case to around 760s ~ 12.7 minutes

Average case time to solve the challenge would be around 21 minutes and worst case to around 38 minutes :(

Borrowing logic from collide_gcm.sage, local copy here is a dirty script

### Solve script

import random
import string
import time

from base64 import b64encode, b64decode
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from Crypto.Cipher import AES
from itertools import product
from bitstring import BitArray, Bits
import pwn

derived_keys = []  # keys derived from scrypt of password
rev_keys = {}  # holds mapping from derived key to password
for k in product(string.ascii_lowercase, repeat=3):
kdf = Scrypt(salt=b'', length=16, n=16, r=8,
p=1, backend=default_backend())
derived_key = kdf.derive("".join(k).encode())
derived_keys.append(derived_key)
rev_keys[derived_key] = "".join(k)

HOST, PORT = "pythia.2021.ctfcompetition.com", 1337
REM = pwn.remote(HOST, PORT)

def bytes_to_element(val, field, a):
"""Converting a bytes object to an element in field"""
bits = BitArray(val)
result = field.fetch_int(0)
for i in range(len(bits)):
if bits[i]:
result += a**i
return result

P.<x> = PolynomialRing(GF(2))
p = x**128 + x**7 + x**2 + x + 1
GFghash.<a> = GF(2**128, 'x', modulus=p)
R = PolynomialRing(GFghash, 'x')

def multicollision(keyset, nonce=b'\x00' * 12, tag=b'\x01' * 16):
"""main function to find multicollisions, Tag is kept a constant
and so is nonce for all the key encryptions
x_bf corresponds byte object x transformed to the field element
"""
L_bytes = int(len(keyset) * 128).to_bytes(16, 'big')
L_bf = bytes_to_element(L_bytes, GFghash, a)
nonce_plus = nonce + bytes([0, 0, 0, 1])
tag_bf = bytes_to_element(tag, GFghash, a)
pairs = []
for k in keyset:
# compute H
aes = AES.new(k, AES.MODE_ECB)
H = aes.encrypt(b'\x00' * 16)
h_bf = bytes_to_element(H, GFghash, a)

s = aes.encrypt(nonce_plus)
s_bf = bytes_to_element(s, GFghash, a)
# assign (lens * H) + s + T to b
b = (L_bf * h_bf) + s_bf + tag_bf
# get pair (H, b*(H^-2))
y = b * h_bf**-2
pairs.append((h_bf, y))
# compute Lagrange interpolation
f = R.lagrange_polynomial(pairs)
ct = ''
for coeff in f.list()[::-1]:
ct = ct.bytes
return ct + tag

def decrypt_text(text):
REM.sendline(b'3')
REM.sendline('A' * 16 + ',' + pwn.b64e(text))
data = REM.recvuntil(b'Exit\n>>> ')
return b'successful' in data

def search(size=367):
start_time = time.time()
api_count = 0
for i in range(0, 26**3, size):
print("trying range ({},{})".format(i, i + size))
api_count += 1
if decrypt_text(multicollision(derived_keys[i:i + size])):
break
lo, hi = i, i + size
while lo <= hi:
mid = (lo + hi) // 2
api_count += 1
print("trying range ({},{})".format(lo, hi))
if decrypt_text(multicollision(derived_keys[lo:mid + 1])):
hi = mid - 1
else:
lo = mid + 1
if decrypt_text(multicollision(derived_keys[lo:lo + 1])):
keyindex = lo
else:
keyindex = lo + 1
print("key:{} found in {} calls".format(password, api_count))
print("time taken :", time.time() - start_time)

REM.recvuntil(b'Exit\n>>>')
for key_index in range(3):
REM.sendline(b'1')  # option1
REM.sendline(str(key_index))
REM.recvuntil(b'Exit\n>>>')

REM.sendline(b'2')
print(REM.recvregex(b'CTF{.*}')
# CTF{gCm_1s_n0t_v3ry_r0bust_4nd_1_sh0uld_us3_s0m3th1ng_els3_h3r3}


And we get our flag!

### CTF{gCm_1s_n0t_v3ry_r0bust_4nd_1_sh0uld_us3_s0m3th1ng_els3_h3r3}

]]>
deuteriumfarziemailid69@gmail.com

## Description

I wrote my own AES! Can you break it?

hQWYogqLXUO+rePyWkNlBlaAX47/2dCeLFMLrmPKcYRLYZgFuqRC7EtwX4DRtG31XY4az+yOvJJ/pwWR0/J9gg==

~qpwoeirut#5057


## Files

from base64 import b64encode

BLOCK_SIZE = 8
ROUNDS = 8

sbox = [111, 161, 71, 136, 68, 69, 31, 0, 145, 237, 169, 115, 16, 20, 22, 82, 138, 183, 232, 95, 244, 163, 64, 229, 224, 104, 231, 61, 121, 152, 97, 50, 74, 96, 247, 144, 194, 86, 186, 234, 99, 122, 46, 18, 215, 168, 173, 188, 41, 243, 219, 203, 141, 21, 171, 57, 116, 178, 233, 210, 184, 253, 151, 48, 206, 250, 133, 44, 59, 147, 137, 66, 52, 75, 187, 129, 225, 209, 191, 92, 238, 127, 241, 25, 160, 9, 170, 13, 157, 45, 205, 196, 28, 146, 142, 150, 17, 39, 24, 80, 118, 6, 32, 93, 11, 216, 220, 100, 85, 112, 222, 226, 126, 197, 180, 34, 182, 37, 148, 70, 78, 201, 236, 81, 62, 42, 193, 67, 8, 164, 43, 252, 166, 221, 208, 176, 235, 149, 109, 63, 103, 223, 65, 56, 140, 255, 218, 54, 153, 2, 228, 1, 240, 248, 246, 110, 156, 60, 227, 207, 254, 51, 174, 79, 128, 155, 251, 242, 177, 135, 230, 154, 179, 15, 189, 143, 130, 27, 107, 211, 30, 105, 19, 134, 124, 125, 245, 76, 204, 12, 26, 38, 40, 131, 117, 87, 114, 213, 212, 102, 195, 101, 55, 10, 47, 120, 200, 217, 88, 83, 36, 198, 249, 192, 23, 94, 181, 73, 185, 172, 165, 58, 53, 202, 106, 5, 7, 175, 89, 72, 90, 14, 162, 158, 119, 139, 77, 108, 190, 91, 29, 49, 159, 33, 113, 214, 4, 123, 199, 167, 35, 239, 84, 3, 132, 98]
pbox = [39, 20, 18, 62, 4, 60, 19, 43, 33, 6, 51, 61, 40, 35, 47, 16, 23, 58, 31, 53, 28, 55, 54, 30, 17, 42, 34, 45, 49, 13, 46, 0, 26, 2, 8, 3, 11, 48, 63, 36, 37, 7, 32, 5, 27, 59, 29, 44, 14, 56, 21, 22, 12, 52, 57, 41, 10, 1, 24, 38, 50, 15, 9, 25]

return block + chr(BLOCK_SIZE - len(block)).encode() * (BLOCK_SIZE - len(block))

def to_blocks(in_bytes: bytes) -> list:
return [in_bytes[i:i + BLOCK_SIZE] for i in range(0, len(in_bytes), BLOCK_SIZE)]

def enc_sub(in_bytes: bytes) -> bytes:
return bytes([sbox[b] for b in in_bytes])

def enc_perm(in_bytes: bytes) -> bytes:
num = int.from_bytes(in_bytes, 'big')
binary = bin(num)[2:].rjust(BLOCK_SIZE * 8, '0')
permuted = ''.join([binary[pbox[i]] for i in range(BLOCK_SIZE * 8)])
out = bytes([int(permuted[i:i + 8], 2) for i in range(0, BLOCK_SIZE * 8, 8)])
return out

def expand_key(key: bytes, key_len: int) -> bytes:
expanded = bytearray()
cur = 0
for byte in key:
cur = (cur + byte) & ((1 << 8) - 1)
expanded.append(cur)
for num in [key[i % len(key)] * 2 for i in range(key_len)]:
cur = pow(cur, num, 256)
expanded.append(cur)
return bytes(expanded)

def encrypt(plain: bytes, key: bytes) -> bytes:
blocks = to_blocks(plain)
out = bytearray()
key = expand_key(key, len(blocks))
for idx, block in enumerate(blocks):
assert len(block) == BLOCK_SIZE
for _ in range(ROUNDS):
block = enc_sub(block)
block = enc_perm(block)
block = bytearray(block)
for i in range(len(block)):
block[i] ^= key[idx]
out.extend(block)
return bytes(out)

if __name__ == '__main__':
with open("flag", 'rb') as flag_file:
with open("key", 'rb') as key_file:
print(b64encode(encrypt(flag, key)).decode())


This seems like a custom AES implementation. Note that we are not provided a decryption routine, so lets simply write one.
Implementing one is not too complicated, one just need to reverse the encrypt function step by step.

def decrypt(cipher: bytes, key:bytes) -> bytes:
blocks = to_blocks(cipher)
out = bytearray()
key = expand_key(key, len(blocks))
for idx, block in enumerate(blocks):
for _ in range(ROUNDS):
block = bytearray(block)
for i in range(len(block)):
block[i] ^= key[idx]
block = dec_perm(block)
block = dec_sub(block)
out.extend(block)
return bytes(out)


Its essentially the encrypt function in reverse, in encrypt, key is xored at last in the for loop, we do it first.
Then we do reverse of permutation dec_perm and reverse of substitution dec_sub in the following functions.

For reversing enc_sub, we just need to find the index of corresponding byte in the sbox

def enc_sub(in_bytes: bytes) -> bytes:
return bytes([sbox[b] for b in in_bytes])

def dec_sub(in_bytes: bytes) -> bytes:
return bytes([sbox.index(b) for b in in_bytes ])


To reverse the permutation,

def enc_perm(in_bytes: bytes) -> bytes:
num = int.from_bytes(in_bytes, 'big')
binary = bin(num)[2:].rjust(BLOCK_SIZE * 8, '0')
permuted = ''.join([binary[pbox[i]] for i in range(BLOCK_SIZE * 8)])
out = bytes([int(permuted[i:i + 8], 2) for i in range(0, BLOCK_SIZE * 8,
8)])
return out

def dec_perm(in_bytes: bytes) -> bytes:
out = bytearray(64)
permuted = bin(int.from_bytes(in_bytes, 'big'))[2:].zfill(64) #just converting to binary
for i in range(64):
out[pbox[i]] = ord(permuted[i]) # should be ones and zeros but using ord as bytearrays are directly convertible to int
out_bytes = int.to_bytes(int(out,2),8,byteorder='big') #converting to bytes again
return out_bytes


Once we have decryption function set up, we can start exploring the challenge :)

The devil at work here is the expand_key function. One could easily verify that without using much brain :)

for i in range(20):
print(expand_key(bytes([i]),8))
# 8 since we know the flag is 8*BLOCK_LENGTH bytes
#b'\x00\x01\x01\x01\x01\x01\x01\x01\x01'
#b'\x01\x01\x01\x01\x01\x01\x01\x01\x01'
#b'\x02\x10\x00\x00\x00\x00\x00\x00\x00'
#b'\x03\xd9\xd1\xe1A\x81\x01\x01\x01'
#b'\x04\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x05\xf9\xf1a\xc1\x81\x01\x01\x01'
#b'\x06\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x07QaA\x81\x01\x01\x01\x01'
#b'\x08\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\t\xd1\xa1A\x81\x01\x01\x01\x01'
#b'\n\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x0b\xe9\xb1!\xc1\x81\x01\x01\x01'
#b'\x0c\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\r\t\x11\xa1A\x81\x01\x01\x01'
#b'\x0e\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x0f!\xc1\x81\x01\x01\x01\x01\x01'
#b'\x10\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x11!A\x81\x01\x01\x01\x01\x01'
#b'\x12\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x13y\x91aA\x81\x01\x01\x01'


Without even looking at the key_expansion, one could say the keys it expands to are quite bad and possibly quite repetitive.
Voila, lets try randomly decrypting with a key.

flag_enc = b64decode(b'hQWYogqLXUO+rePyWkNlBlaAX47/2dCeLFMLrmPKcYRLYZgFuqRC7EtwX4DRtG31XY4az+yOvJJ/pwWR0/J9gg==')
print(decyrpt(flag_enc, b'\x00'))
print(decyrpt(flag_enc, b'\x01'))


One could aready read a lot of the flag! We only lack the first two blocks of the flag.
Why can we read the rest of the flag by decrypting with some non-sense key?
Since in encrypt function each block is xored with the key byte at the corresponding position, we luckily end up encrypting it with byte b'\x01' for the last 6 bytes.
And xoring with b'\x01' would be the same byte again hehe.
But wouldnt it be lost amidst all the permutation and substitution??
No, since we are exactly reversing the permutation and substitution since the xor part dies out!

Why do we have so many 0’s and 1’s in the expanded key?
It is evident from this part

for num in [key[i % len(key)] * 2 for i in range(key_len)]:
cur = pow(cur, num, 256)
expanded.append(cur)


As cur is repeteadly raised to the power num, once cur hits 0 or 1, it will stay 0 or 1 out of its misery.
So all we need to figure out is the first two bytes, which should be quite easy!

for i in range(65536):
key = long_to_bytes(i)
if (a := decrypt(flag_enc, key)).startswith(b'rgbCTF'):
print(a,key)


Ugly solution in solve.py

And boom! we have our flag

### rgbCTF{brut3_f0rc3_is_4LW4YS_th3_4nsw3r(but_with_0ptimiz4ti0ns)}

]]>
deuteriumfarziemailid69@gmail.com
rgbCTF 2020 Crypto - N-AES2020-07-13T00:00:00+05:302020-07-13T00:00:00+05:30https://deut-erium.github.io/WriteUps/2020/rgbctf/crypto/N-AES/rgbCTF-2020-N-AESN-AES

## Description

What if I encrypt something with AES multiple times? nc challenge.rgbsec.xyz 34567

~qpwoeirut#5057


## Files

import binascii
from base64 import b64encode, b64decode
from Crypto.Cipher import AES
from os import urandom
from random import seed, randint

BLOCK_SIZE = 16

def rand_block(key_seed=urandom(1)):
seed(key_seed)
return bytes([randint(0, 255) for _ in range(BLOCK_SIZE)])

def encrypt(plaintext, seed_bytes):
seed_bytes = b64decode(seed_bytes)
assert len(seed_bytes) >= 8
for seed in seed_bytes:
ciphertext = AES.new(rand_block(seed), AES.MODE_ECB).encrypt(ciphertext)

return b64encode(ciphertext)

def decrypt(ciphertext, seed_bytes):
plaintext = b64decode(ciphertext"rgbCTF 2020 Crypto - N-AES"    seed_bytes = b64decode(seed_bytes)
for byte in reversed(seed_bytes):
plaintext = AES.new(rand_block(byte), AES.MODE_ECB).decrypt(plaintext)

def gen_chall(text):
for i in range(128):
text = AES.new(rand_block(), AES.MODE_ECB).encrypt(text)

return b64encode(text)

def main():
challenge = b64encode(urandom(64))
print(gen_chall(challenge).decode())
while True:
print(" Encrypt")
print(" Decrypt")
print(" Solve challenge")
print(" Give up")

command = input("> ")

try:
if command == '1':
text = input("Enter text to encrypt, in base64: ")
seed_bytes = input("Enter key, in base64: ")
print(encrypt(text, seed_bytes))
elif command == '2':
text = input("Enter text to decrypt, in base64: ")
seed_bytes = input("Enter key, in base64: ")
print(decrypt(text, seed_bytes))
elif command == '3':
answer = input("Enter the decrypted challenge, in base64: ")
print("Correct!")
with open("flag", 'r') as f:
else:
print("Incorrect!")
break
elif command == '4':
break
else:
print("Invalid command!")
except binascii.Error:
print("Base64 error!")
except Exception:
print("Error!")

print("Bye!")

if __name__ == '__main__':
main()


On netcatting, we get get a base64 encoded encryption of a base64 encoded random string of 64 bytes.

challenge = b64encode(urandom(64))
print(gen_chall(challenge).decode())


Taking a look at gen_chall

def gen_chall(text):
for i in range(128):
text = AES.new(rand_block(), AES.MODE_ECB).encrypt(text)

return b64encode(text)


And

def rand_block(key_seed=urandom(1)):
seed(key_seed)
return bytes([randint(0, 255) for _ in range(BLOCK_SIZE)])


This seems quite tricky, since rand_block will be presenting some random key and gen_chall is encrypting with some random key 128 times! right?
WRONG, There are some few caveats which we might exploit ;)

• Since no key_seed is specified in the gen_chall call to rand_block, it should be taking key_seed to be urandom(1) which is simply one byte :)
• More importantly, once it gets called, key_seed is fixed! So all the random blocks would essentially be the same! One may test it out.
from os import urandom
from random import seed, randint

BLOCK_SIZE = 16

def rand_block(key_seed=urandom(1)):
seed(key_seed)
return bytes([randint(0, 255) for _ in range(BLOCK_SIZE)])

for i in range(10):
print(rand_block())

#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'


So all that needs to be done is find out that random byte with which seed was initialised, and we will know the key, just decrypt our way out of the flag.

## Solution

from pwn import remote
from base64 import b64encode, b64decode
from Crypto.Cipher import AES
from random import seed, randint
import re
HOST, PORT = "challenge.rgbsec.xyz", 34567
REM = remote(HOST, PORT)

CHALL = b64decode(REM.recvline().strip())
def rand_block(byte):
"""random block for given seed byte"""
seed(byte)
return bytes([randint(0,255) for _ in range(16) ])

REM.recvuntil(b'\n>')

def dec_serv(ciphertext, seed_bytes):
"""Requests decryption from the server"""
REM.sendline(b'2')
REM.sendline(b64encode(ciphertext))
REM.sendline(b64encode(seed_bytes))
data = REM.recvuntil(b'\n>')
if b'Error' not in data:
decd = re.search(b'b\'([a-zA-Z0-9\+/]+)\'',data)
return b64decode(decd)

for i in range(256):
decryption = dec_serv(CHALL, bytes([i]*128))
if decryption:
print(decryption)
break

REM.sendline(b'3')
REM.sendline(b64encode(decrypt(CHALL)))
print(REM.recvregex(b'rgbCTF{.*}').decode())


### WAIT! THAT WONT WORK!!

Tbh, I expected that to work but it didnt! Why?
Because server uses this decryption routine

def decrypt(ciphertext, seed_bytes):
plaintext = b64decode(ciphertext)
seed_bytes = b64decode(seed_bytes)
for byte in reversed(seed_bytes):
plaintext = AES.new(rand_block(byte), AES.MODE_ECB).decrypt(plaintext)



Still cant spot it out?
All the devil is in rand_block(byte). How? Because when byte objects are iterated upon, all the individual bytes are returned as int.

for i in b'a':
print(i,type(i))

#97 <class 'int'>


Hmm, very interesting. But how does that make a difference?
Because rand_block(i) and rand_block(byte([i]) are completly different for an int i! Why?
Because internally seed(key_seed) is used to initialize, and seed(byte([i])) and seed(i) are different! WTF!!

This implies the server would not never be able to decrypt using its own decryption routine!

To fix this, all we need to do is to write our own!
We know the decryption is correct just by looking at correct padding, since len(b64encode(64 random bytes)) = 64*4/3 = 85 and we have a ciphertext of len 96.

1 in AES initialization suggests AES.MODE_ECB

def decrypt(ct):
ct_orig = ct
for i in range(256):
ct = ct_orig
for _ in range(128):
ct = AES.new(rand_block(bytes([i])),1).decrypt(ct)
try:
except:
continue


Putting the final script in solve.py

from pwn import remote
from base64 import b64encode, b64decode
from Crypto.Cipher import AES
from random import seed, randint
import re
HOST, PORT = "challenge.rgbsec.xyz", 34567
REM = remote(HOST, PORT)

CHALL = b64decode(REM.recvline().strip())
def rand_block(byte):
"""random block for given seed byte"""
seed(byte)
return bytes([randint(0,255) for _ in range(16) ])

REM.recvuntil(b'\n>')

def decrypt(ct):
ct_orig = ct
for i in range(256):
ct = ct_orig
for _ in range(128):
ct = AES.new(rand_block(bytes([i])),1).decrypt(ct)
try:
except:
continue

REM.sendline(b'3')
REM.sendline(b64encode(decrypt(CHALL)))
print(REM.recvregex(b'rgbCTF{.*}').decode())


### rgbCTF{i_d0nt_7hink_7his_d03s_wh47_y0u_7hink_i7_d03s}

]]>
deuteriumfarziemailid69@gmail.com
0CTF/TCTF 2020 Crypto - babyring2020-06-30T00:00:00+05:302020-06-30T00:00:00+05:30https://deut-erium.github.io/WriteUps/2020/tctf/crypto/babyring/TCTF-2020-Quals-babyringBabyring

## Description

nc pwnable.org 10001


## Files

#!/usr/bin/python2
import os,random,sys,string
from hashlib import sha256
from struct import pack, unpack
import SocketServer
from Crypto.Cipher import ARC4

from flag import flag

K = 64

def gen():
from Crypto.Util.number import getStrongPrime
e = 65537
Ns = []
for i in range(K):
p = getStrongPrime(2048)
q = getStrongPrime(2048)
Ns.append(p*q)
return e,Ns

e,Ns = 65537,#list trimmed[...]
def proof_of_work(self):
proof = ''.join([random.choice(string.ascii_letters+string.digits) for _ in xrange(20)])
digest = sha256(proof).hexdigest()
self.request.send("sha256(XXXX+%s) == %s\n" % (proof[4:],digest))
self.request.send('Give me XXXX:')
x = self.request.recv(10)
x = x.strip()
if len(x) != 4 or sha256(x+proof[4:]).hexdigest() != digest:
return False
return True

def handle(self):
if not self.proof_of_work():
return
self.request.settimeout(3)
try:
self.request.sendall("message: ")
msg = self.request.recv(0x40).strip()
ys = []
for i in range(K):
self.request.sendall("x%d: " % i)
x = int(self.request.recv(0x40).strip())
ys.append(pow(x,e,Ns[i]))
self.request.sendall("v: ")
v = int(self.request.recv(0x40).strip())

key = sha256(msg).digest()[:16]
E = ARC4.new(key)
cur = v
for i in range(K):
pt = (ys[i]^cur)%(1<<64)
ct = unpack('Q', E.encrypt(pack('Q',pt)))
cur = ct

if cur == v:
self.request.sendall("%s\n" % flag)
self.request.sendall("fin\n")
finally:
self.request.close()

pass

if __name__ == "__main__":
HOST, PORT = '0.0.0.0', 10001
server.serve_forever()


The first part is obviously proof of work, in which we have to find 4 bytes XXXX such that sha256(XXXX + 16-char-val) = sha256_hash for provided 16-char-val postfix and sha256_hash
Which is easy to solve

using permutations may not always work (in case of repeated characters), earlier I used combinations_with_replacement which had some weird issues which I could not debug python from hashlib import sha256 import string from itertools import permutations as take CHARSET_SHA = string.printable[:62].encode() #0-9a-zA-Z as in challenge

def pow_sha(postfix, hash_val): for prefix in take(CHARSET_SHA, 4): prefix_bytes = bytes(prefix) shaa = sha256(prefix_bytes+postfix).hexdigest() if shaa == SHA_256_HASH: return prefix_bytes

HOST, PORT = “pwnable.org”, 10001 REM = remote(HOST, PORT) SHA_CHALL = REM.recvuntil(b’XXXX:’) #print(SHA_CHALL.decode()) SHA_256_HASH = re.search(b”[0-9a-f]{64}”,SHA_CHALL).group(0).decode() POSTFIX_STR = re.search(b”[0-9a-zA-Z]{16}”,SHA_CHALL).group(0) PREFIX_CHALL = pow_sha(POSTFIX_STR, SHA_256_HASH) REM.send(PREFIX_CHALL)


Now comes the main part of the challenge, in which we have to provide 64 xi values a message msg and a value v
The msg is sha256 hashed and first 16 bytes are taken to form the key for ARC4
The value v is XORed with last 64 bits of pow(x[i], e, Ns[i]) and then ARC4 encrypted. The goal is to produce the final value equal to the input v.

Since ARC4 is simply a stream cipher, and encryption is just XORing the plaintext with a keystream, our final value cur is essentially v^ys^...ys^xors^xors...^xors, where ys[0..63] are the last 64 bits of the respective y[0..63]  and xors^xors...^xors part is essentially dependent on key and an invariant for a given key, lets call it invariant(key) (bye bye ARC4).
All we need to do is to find x[0..63] such that ys^ys...^ys == invariant(key) and we will have cur==v for all v as a consequence.

python
from Crypto.Cipher import ARC4
from hashlib import sha256
from struct import pack, unpack

def encrypt_64(v,key,y):
E = ARC4.new(key)
cur = v
for i in range(64):
pt = (cur^y[i])%(1<<64)
ct = unpack('Q',E.encrypt(pack('Q',pt)))
cur = ct
return cur

def invariant(key):
key_val = sha256(key).digest()[:16]
return encrypt_64(0,key_val,[0 for i in range(64)])

print(invariant(b'aaa'))
# 911494890333775973


One could simply put x[i] as some value such that ys[i] == invariant and all other xs == 0 but only if one could solve ANY of the RSA by factoring 4096 bit Ns, which is obviously not feasible!

Not knowing much linear algebra, I found this stackexchange post and this showing all that needs to be done is to have a set of 64 64-bit vectors, and we can represent any 64 bit value using xor of a subset of the vectors. Taking the corresponding ys for xs = 2 for all i, and solving the subset for given invariant, we will only set x[i] = 2 in the i in subset else x[i]=0 (to have no effect).

Sagemath ftw! CoCalc for the poor

#Ns not shown here
last_64 = [pow(2,e,i)%(1<<64) for i in Ns]
invariant = 911494890333775973 #for msg = b'aaa'
I = GF(2**64)
last_64_mat = [ list(map(int, bin(i)[2:].zfill(64))) for i in last_64  ]
mat = matrix(I,last_64_mat)
invariant_vec = list(map(int, bin(invariant)[2:].zfill(64)))
invariant_vec = matrix(I,invariant_vec)
op = mat.solve_left(invariant_vec)
print(op)


Awesome! we have (0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1) as our output vector, we just have to return xi as 2*op and we are done ;)

xs = (0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1)
xs = [2*i for i in xs]
REM.recv()
REM.send(b'aaa') #message
for i in range(64):
REM.send(str(xs[i]).encode()) #xi's
REM.recv()

REM.send(b'0') #v any v would do the job ;)
REM.recv()
#flag{babbbcbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbubvbwbxby}


Unorganized code in files solve.py, part2.sage and test.py

### flag{babbbcbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbubvbwbxby}

]]>
deuteriumfarziemailid69@gmail.com

## Description

Everyone's favorite guess god Tux just sent me a flag that he somehow encrypted with a color wheel!

I don't even know where to start, the wheel looks more like a clock than a cipher... can you help me crack the code?


## Files  Lets think like a clock, and start numbering colors from 0-11 And if we map the corresponding numbers, we get 86 90 81 87 a3 49 99 43 97 97 41 92 49 7b 41 97 7b 44 92 7b 44 96 98 a5


Now, we know that the flag begins with the prefix flag{, which helps us easily guess what it is, since 'f' and 'l' differ by 4, here the ciphertext also differs by 4 i.e 90-86. Also, 'l' and 'a' differ by 11, which confirms, that it is base 12 encoding.

Voila, here we go

EXTRACTED = '86 90 81 87 a3 49 99 43 97 97 41 92 49 7b 41 97 7b 44 92 7b 44 96 98 a5'

flag = ''.join([chr(int(i,12)) for i in EXTRACTED.split()])
print(flag)


### flag = flag{9u3ss1n9_1s_4n_4rt}

]]>
deuteriumfarziemailid69@gmail.com
Redpwn 2020 Crypto - 4k-rsa2020-06-27T00:00:00+05:302020-06-27T00:00:00+05:30https://deut-erium.github.io/WriteUps/2020/redpwn/crypto/4k-rsa/redpwn-2020-4k-rsa4k-rsa

## Description

Only n00bz use 2048-bit RSA. True gamers use keys that are at least 4k bits long, no matter how many primes it takes...


## Files

4k-rsa-public-key.txt which contains a n, e, c triple

Seems like there are a lot of primes in the factorization of n, since the factorization process is influenced directly by the size of prime factors and not the size of the number being factored itself, it should be fairly doable by alpetron.ar It took about half an hour to factor, one may engage to other activities or alternatively try if the factors are available on factordb.
Anyways, once finished factoring, alpetron produces both the factors and the Euler’s totient phi which will be used to compute d

d = pow(e,-1,phi) # on python3.8
# or gmpy2.invert(e,phi)
m = pow(c,d,n)
print(bytes.fromhex(hex(m)[2:]).decode())
`

And hurray, we have our flag

### flag{t0000_m4nyyyy_pr1m355555}

]]>
deuteriumfarziemailid69@gmail.com