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 variables min, max and num.
  • The name of the function is _answer_func.
  • The function originates from current __file__.
  • The function's bytecode is co_code which is co_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.

References

Author: calx

Created: 2024-05-30 Thu 05:24

Emacs 29.3 (Org mode 9.6.15)