╔══════════════════════════════════════════════════════════════════════════════════╝ ║CRYPTO ANDOR HER0CTF WRITEUP 12/12/2025 ╚══════════════════════════════════════════════════════════════════════════════════╗┏━━┓ BACK┗━━┛ we are given the following: "Would you rather be inside solving challenges AND getting flags OR outside touching grass ?" as well as a connection: nc crypto.heroctf.fr 9000 and a zip file "andor.zip" ==================================================================================================== first impressions: when connecting to the server, we get the following: -> a = 4840502d1028303459140c60403028023350092052405110641734680053 -> o = fbfd777e3f3f7f7ff5f5befd3f5fe6ef36ff5f3eeeee7ff5b2b97fbfff7d two 60 char long hex numbers each time we query the server, we get two different numbers: -> a = 4064104523792001071022600624202303201930600a00005000346e044f -> o = 7732e77fb3bb7f37f57dff6cfdff77edf7775f776e6fdf7678fb7eff75fd as an observation, the first number seems to often begin with 4, and the second with 7 lets take a look at the source code provided in the zip file: ---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- #!/usr/bin/env python3 import secrets AND = lambda x, y: [a & b for a, b in zip(x, y)] IOR = lambda x, y: [a | b for a, b in zip(x, y)] with open("flag.txt", "rb") as f: flag = [*f.read().strip()] l = len(flag) // 2 while True: k = secrets.token_bytes(len(flag)) a = AND(flag[:l], k[:l]) o = IOR(flag[l:], k[l:]) print("a =", bytearray(a).hex()) print("o =", bytearray(o).hex()) input("> ") ---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- oh yuck - I hate lamda functions well, lets try working out what this does there are two functions; AND and IOR (IOR seems like a intentional derivitive of XOR?) AND: takes two args (x and y) returns x & y IOR takes two args (x and y) returns x | y i think this is the bitwise OR operand? as opposed to ||, the logical OR operator (this is probably why its called IOR -> "inclusive" XOR) then "flag.txt" is opened with "rb" as an argument? documentation -> rb = read and byte mode (so that the bitwise operators can work - makes sense) variable "l" is set to helf the length of this bytestring variable "k" (probably a key) is then set to = secrets.token_bytes(len(flag)) documentation says -> just generates a series of bytes of the length specified (here the same length as our flag) i can never rememebr which way round the [:] trims things - let me test it myself---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- string = "abcdef" string1 = string[:3] string2 = string[3:] print(f"[:3] = {string1}") print(f"[3:] = {string2}") ---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- yeilds -> [:3] = abc -> [3:] = def the digit represents the "side" and how many characters are trimmed from each side variable "a" = AND(first_half(flag), first_half(key)) variable "o" = IOR(second_half(flag), second_half(key)) then it displays these variables as hex strings to the console so lets say that our flag is "flag" our bytestring for flag would be: -> [102, 108, 97, 103] then we generate a random byte string as a key: -> lets use [100, 101, 102, 103] then we AND the first_half(flag) with first_half(key) -> AND([102, 108], [100, 101]) = a IOR the second_half(flag) with second_half(key) -> IOR([97, 103], [102, 103]) = o ==================================================================================================== so how do we go about retrieving the flag from this? we have access to an unlimited (within reason) number of encryptions of the flag - so this feels like an "oracle" implementation if we only ever got one "a", "o" pair - this wouldn't be possible (i think) - but what can we do with lots of pairs... there are 256 possible bit combinations for a byte, are there some that we can try filter for? like if the byte contains only 00000000? wouldn't do anything for AND (as x AND 0 = 0) would reveal the flag for IOR (as x OR 0 = x) unfortunatley we can't know when the random byte would be 00000000 - as the two halves of the key don't overlap in usage (24hrs later) since: 0 AND 1 = 0 0 AND 0 = 0 if there are bits in "a" that always == 0, then we can say that the corresponding bits in the flag are also 0 therefore, as: 1 AND 1 = 1 1 AND 0 = 0 if there are bits in "a" that change from 1 to 0, then we can say that the corresponding bit in the flag are (likley) 1 i think we can use OR to do our comparisons for us, for a(x): a(1) OR a(2) -> will show a 1 in all the bit-positions that "changed" - and are therefore 1s in the flag portion. if we look at our result from above, and XOR it with a(3) -> if any of the 0s change to 1s it will inherit them (as 1 OR 1 = 1 - this will preserve the "positions" of the changing bits in our answer) so time for python (this took me longer than i'd like to admit):---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- a_outputs = [0x4064004f230800551e0068201e24401023305130425f0020345514684412, 0x00014046214020451a3060200a152020026045202207591004033446604f, 0x404562226a083071132440640301481002605a1070133110010a100e000b, 0x00014046214020451a3060200a152020026045202207591004033446604f, 0x404562226a083071132440640301481002605a1070133110010a100e000b, 0x4845702b28590051571046405534203111600b2030444010400c00684441, 0x0000404d616930101b104c404f356c0000005600201a3810011c30084400, 0x0004324d1a011031063444440e354c222140472002097120041600400459, 0x406012621a3820044504224059040813235014300251001015543002205e, 0x0820324941383024431000004a3528222140542010415930701f004c4447, 0x4004306f0270005047346a401904400110204e1050022830155a20440055, 0x480140222909205059006444190524001110132052131930711f040e2040] def OR_chain(outputs): result = 0 for value in outputs: result = result | value return hex(result) def readable(num): hex_str = num[2:] if len(hex_str) % 2 == 1: hex_str = "0" + hex_str return bytes.fromhex(hex_str) if __name__ == "__main__": x = OR_chain(a_outputs) y = readable(x) print(y) ---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- this yields: -> Hero{y0u_4nd_5l33p_0r_y0u_4nd_ which looks like the first half :) now for the second, IOR section - lets see if there are any combinations that result in stable bits: 1 OR 1 = 1 (this would be 0 in XOR) 1 OR 0 = 1 0 OR 1 = 1 0 OR 0 = 0 so if the bit in the flag is a 1 -> the resulting bit will always be 1 if the bit in the flag is 0 -> then the resulting bit can be a 1 or 0 i think we can use AND here to sort our outputs for us o(1) AND o(2) -> will give us 1s only where both of them have a 1 in a particular bit-position as the moment a single 0 in a bit-position is AND-ed it will propogate through all the rest as a 0 - we can sort our bits time for more python:---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- def AND_chain(outputs): result = 0 for value in outputs: result = result & value return hex(result) ---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- i tried this, but it didn't work - and ended up zero-ing out all the bytes oh its because i set the result variable to 0 -> which propogates 0s through all of the subsequent bytes we need it to be: result = 11111... how many 1s do we need? the same number as there are bits in our "o" results (if written as binary) or the hex representation of that (0xffffffff...) the byte_count we would need = bit_length of "o" + 7 // 8 (so that we guarantee non padded bits are counted) then we can construct our result variable to be:---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- byte_count = (o_outputs[0].bit_length() + 7) // 8 result = int.from_bytes(b'\xff' * byte_count, 'big') ---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- eyy that works! our reconstructed flag: -> Hero{y0u_4nd_5l33p_0r_y0u_4nd_c0ff33_3qu4l5_fl4g_4nd_p01n75}┏━━┓ BACK┗━━┛