neven@dimitri-dev:~$
cd ../hackultet-2026

$cat povratak-nemoguce-jednadzbe.mdMiscHackultet 2026

Povratak Nemoguće Jednadžbe (Return of the Impossible Equation)

A calculator for a Lisp-like language guards its secret by giving back a plain int instead of the special object that holds the flag, which Python's operator-overloading rules turn out to undo in 56 characters.

  • #python-internals
  • #sandbox-escape
  • #operator-overloading

$man povratak-nemoguce-jednadzbe

What this one teaches:

  • Why int.__add__ always returns a plain int, discarding any subclass, even one with extra attributes attached.
  • Python's __radd__ fallback, called when the left operand's own __add__ can't handle the right-hand type.
  • Reaching a blocked built-in (type) through an object's own metaclass chain instead of calling it by name.

Task description

A prefix-notation (Polish notation) calculator for the Hy language (a Lisp that compiles to Python). Make the program print a flag stored as an attribute on a custom int.

The name is a sequel-style pun: “nemoguća jednadžba” means “impossible equation,” and “povratak” means “return,” both as in “coming back” and, fittingly for this task, as in the return from a function call.

What the server does

class OOO00OO00O00(int):          # a custom int subclass, nothing overridden
    None

obj = OOO00OO00O00(0)
obj.flag = os.environ.get('FLAG', '...')

user_input = input('Calculation: ')
if len(user_input) > 58: fail()
if 'flag' in user_input.lower(): fail()

restricted_globals = {
    'type': BLOCKED, 'object': BLOCKED, 'fn': BLOCKED,
    'defn': BLOCKED, 'defclass': BLOCKED, 'lambda': BLOCKED,
    '__builtins__': {'__import__': hy_only_import},
}

result = hy.eval(hy.read(user_input), restricted_globals)
combined = obj + result
print('Result:', combined)
if hasattr(combined, 'flag'):
    print(combined.flag)

Constraints: 58 characters maximum, the literal substring flag is banned (case-insensitively), and several names that would make an obvious escape easy are explicitly blocked in the evaluation globals.

Why the straightforward approach fails

obj holds the flag as an attribute, but obj + result is computed with plain Python addition, and int.__add__ always returns a fresh, ordinary int, no matter that obj is actually an instance of a subclass with extra attributes attached. Whatever result evaluates to, the addition itself discards the subclass and its .flag attribute along with it; combined is never going to be obj by this route, however cleverly result is chosen on its own.

Reaching around the blocked names

Two observations about Hy and Python matter here:

  1. fn is a Hy special form, compiled directly to a Python lambda at the AST level by Hy’s own compiler, so putting fn in the blocked-globals dictionary has no effect: Hy never looks it up as a regular name in the first place. Functions remain fully usable.
  2. type is blocked by name, but reachable through any object’s own metaclass chain. Every object’s __class__ is its type, and every type’s own __class__ is type itself (or close enough, for a plain int). 0.__class__.__class__ is type, retrieved without ever naming the blocked identifier directly.

The actual gap: __radd__

Python’s addition protocol has a fallback: if the left operand’s __add__ returns NotImplemented for the right operand’s type, Python tries the right operand’s own __radd__ instead, and whatever that returns becomes the result of the whole expression. int.__add__ does exactly that when asked to add something it doesn’t recognise as numeric, which clears the way to define a class whose __radd__ just hands back whichever object was on the left of the +, meaning the original obj, flag attribute intact, comes right back out as the result of the addition.

The payload

(((. 0 __class__ __class__) "" #() {"__radd__" (fn [s o] o)}))

56 characters, no banned substring. Reading it piece by piece:

Fragment Meaning
(. 0 __class__ __class__) 0.__class__.__class__, reaching type without naming it
type("", #(), {"__radd__": ...}) constructing a new class on the fly, with one method
(fn [s o] o) a lambda that ignores self and returns the other operand, o
the outer (...) instantiates that freshly built class

Why it works

  1. hy.eval returns an instance of this freshly built class.
  2. obj + instance: int.__add__(obj, instance) can’t handle a non-numeric right-hand side and returns NotImplemented, so Python falls back to instance.__radd__(obj), which was defined to just return obj.
  3. combined is therefore obj itself, not a new plain int, so its .flag attribute survives intact.
  4. hasattr(combined, 'flag') is now true, and the server prints it:
CTF2026[532955405732]

What the sandbox actually failed to anticipate

Blocking a handful of named identifiers (type, object, lambda, defn, defclass) assumes those names are the only paths to the capabilities they represent. In a language with a reflective object model, almost every one of those capabilities is also reachable through ordinary attribute access on objects that are never themselves blocked, here, a plain int literal’s own class hierarchy, and a special form (fn) that the sandbox’s name-based filter couldn’t see at all, since Hy had already compiled it away before the filter ever ran.

$cat flag.txt revealCTF2026[532955405732]

Written by Neven Marić. This writeup was written with the help of Claude Code (Anthropic).