Cubicle Riddle
Flag
HTB{r1ddle_m3_th1s_r1ddle_m3_th4t}
Payload
124, 0, 68, 0, 93, 18, 125, 3, 124, 3, 124, 2, 107, 4, 0, 0, 0, 0, 114, 2, 124, 3, 125, 2, 124, 3, 124, 1, 107, 0, 0, 0, 0, 0, 114, 2, 124, 3, 125, 1, 140, 19
Intro
File: riddler/riddler.py
import types from random import randint class Riddler: max_int: int min_int: int co_code_start: bytes co_code_end: bytes num_list: list[int] def __init__(self) -> None: self.max_int = 1000 self.min_int = -1000 self.co_code_start = b"d\x01}\x01d\x02}\x02" self.co_code_end = b"|\x01|\x02f\x02S\x00" self.num_list = [randint(self.min_int, self.max_int) for _ in range(10)] def ask_riddle(self) -> str: return """ 'In arrays deep, where numbers sprawl, I lurk unseen, both short and tall. Seek me out, in ranks I stand, The lowest low, the highest grand. What am i?' """ def check_answer(self, answer: bytes) -> bool: _answer_func: types.FunctionType = types.FunctionType( self._construct_answer(answer), {} ) return _answer_func(self.num_list) == (min(self.num_list), max(self.num_list)) def _construct_answer(self, answer: bytes) -> types.CodeType: co_code: bytearray = bytearray(self.co_code_start) co_code.extend(answer) co_code.extend(self.co_code_end) code_obj: types.CodeType = types.CodeType( 1, 0, 0, 4, 3, 3, bytes(co_code), (None, self.max_int, self.min_int), (), ("num_list", "min", "max", "num"), __file__, "_answer_func", "_answer_func", 1, b"", b"", (), (), ) return code_obj
Our payload (numbers seperated by comma) is accepted by check_answer
function as a bytearray (python bytecode), which then calls
_construct_answer
to create a function out of the bytearray. For
doing so, _construct_answer
wraps the bytearray in co_code_start
and co_code_end
bytearrays. Finally, check_answer
calls the
generated function with num_list
array of numbers and expects the
function to return a tuple of min and max number in the array.
As for the function that will be generated, we see the constraints (of
sorts) our function will be having, by looking at the
types.CodeType(...)
function call. Now, this part was confusing for
me because of improper match up with the documentation, but the
conclusion I arrived at was:
- The function accepts one parameter,
num_list
and has 3 other local variablesmin
,max
andnum
. - The name of the function is
_answer_func
. - The function originates from current
__file__
. - The function's bytecode is
co_code
which isco_code_start + payload + co_code_end
. - The initial values are:
num_list := None
min := max_int := 1000
max := min_int := -1000
One mistake I made
Initially, I assumed min
is already getting max_int
and max
is
already getting min_int
. So, all we have to do, is to swap both
values using the extra provided variable num
. I tried to submit
that, which obviously didn't work, lol. As min_int
and max_int
define the range, but the real max and min values are the friends we
made along the way somewhere in between min_int
and max_int
. And,
we need to scan the num_list
to find them.
Walkthrough
We needed a function that takes a list of numbers (num_list
) and
returns a tuple containing the minimum and the maximum numbers from
the list (return (mn, mx)
). So, here's one such function:
def func(num_list): mn = 5 mx = 1 for num in num_list: if num > mx: mx = num if num < mn: mn = num return (mn, mx)
Disassembling the function:
import dis
dis.dis(func)
1 0 RESUME 0 2 2 LOAD_CONST 1 (5) 4 STORE_FAST 1 (mn) 3 6 LOAD_CONST 2 (1) 8 STORE_FAST 2 (mx) 4 10 LOAD_FAST 0 (num_list) 12 GET_ITER >> 14 FOR_ITER 17 (to 52) 18 STORE_FAST 3 (num) 5 20 LOAD_FAST 3 (num) 22 LOAD_FAST 2 (mx) 24 COMPARE_OP 68 (>) 28 POP_JUMP_IF_FALSE 2 (to 34) 30 LOAD_FAST 3 (num) 32 STORE_FAST 2 (mx) 6 >> 34 LOAD_FAST 3 (num) 36 LOAD_FAST 1 (mn) 38 COMPARE_OP 2 (<) 42 POP_JUMP_IF_TRUE 1 (to 46) 44 JUMP_BACKWARD 16 (to 14) >> 46 LOAD_FAST 3 (num) 48 STORE_FAST 1 (mn) 50 JUMP_BACKWARD 19 (to 14) 4 >> 52 END_FOR 7 54 LOAD_FAST 1 (mn) 56 LOAD_FAST 2 (mx) 58 BUILD_TUPLE 2 60 RETURN_VALUE
Some unnecessary tests:
print(dis.code_info(func))
Name: func Filename: <stdin> Argument count: 1 Positional-only arguments: 0 Kw-only arguments: 0 Number of locals: 4 Stack size: 3 Flags: OPTIMIZED, NEWLOCALS Constants: 0: None 1: 5 2: 1 Variable names: 0: num_list 1: mn 2: mx 3: num
The function bytecode:
dis.Bytecode(func).codeobj.co_code
b'\x97\x00d\x01}\x01d\x02}\x02|\x00D\x00]\x11\x00\x00}\x03|\x03|\x02kD\x00\x00r\x02|\x03}\x02|\x03|\x01k\x02\x00\x00s\x01\x8c\x10|\x03}\x01\x8c\x13\x04\x00|\x01|\x02f\x02S\x00'
Trying to match the parts of code with co_code_start
and
co_code_end
:
THE PART WE NEED v---------------------------------------------------------------------------------------------------------------------------v b'\x97\x00d\x01}\x01d\x02}\x02|\x00D\x00]\x11\x00\x00}\x03|\x03|\x02kD\x00\x00r\x02|\x03}\x02|\x03|\x01k\x02\x00\x00s\x01\x8c\x10|\x03}\x01\x8c\x13\x04\x00|\x01|\x02f\x02S\x00' b"d\x01}\x01d\x02}\x02" b"|\x01|\x02f\x02S\x00" ^ co_code_start ^ co_code_end
Extracting payload from bytearray:
co = b"|\x00D\x00]\x11\x00\x00}\x03|\x03|\x02kD\x00\x00r\x02|\x03}\x02|\x03|\x01k\x02\x00\x00s\x01\x8c\x10|\x03}\x01\x8c\x13\x04\x00" print(",".join(map(str, [b for b in co])))
124,0,68,0,93,17,0,0,125,3,124,3,124,2,107,68,0,0,114,2,124,3,125,2,124,3,124,1,107,2,0,0,115,1,140,16,124,3,125,1,140,19,4,0
Although, this payload is different from the one I arrived at, at the time of the CTF, it still works.