siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

CrashPython

Table of Contents

  1. Overview
  2. Background
  3. Find the flag
  4. Conclusion

Overview

Background

I am hosting an uncrashable Python executor API. Crash it to get the flag.

http://34.124.157.94:5000

Find the flag

Home page:

In here, this web application is an online Python interpreter, which runs Python code.

In this challenge, we can download a file:

┌[siunam♥earth]-(~/ctf/Grey-Cat-The-Flag-2023-Qualifiers/Misc/CrashPython)-[2023.05.20|20:42:32(HKT)]
└> file dist.zip 
dist.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam♥earth]-(~/ctf/Grey-Cat-The-Flag-2023-Qualifiers/Misc/CrashPython)-[2023.05.20|20:42:34(HKT)]
└> unzip dist.zip 
Archive:  dist.zip
   creating: nginx/
  inflating: nginx/dockerfile        
  inflating: nginx/nginx.conf        
  inflating: nginx/project.conf      
  inflating: docker-compose.yml      
  inflating: judge0.conf             
   creating: app/
  inflating: app/app.py              
  inflating: app/constants.py        
  inflating: app/dockerfile          
 extracting: app/requirements.txt    
   creating: app/templates/
  inflating: app/templates/base.html  
  inflating: app/templates/index.html  
  inflating: app/templates/results.html  
  inflating: app/wsgi.py             

Let’s look at docker-compose.yml first:

version: '3.7'

x-logging:
  &default-logging
  logging:
    driver: json-file
    options:
      max-size: 100M

services:
  flask_app:
    container_name: flask_app
    restart: always
    build: ./app
    command: gunicorn -w 1 -b 0.0.0.0:8000 wsgi:app

  nginx:
    container_name: nginx
    restart: always
    build: ./nginx
    ports:
      - "5000:80"
    depends_on:
      - flask_app

  server:
    image: judge0/judge0:latest
    volumes:
      - ./judge0.conf:/judge0.conf:ro
    ports:
      - "2358:2358"
    privileged: true
    <<: *default-logging
    restart: always

  worker:
    image: judge0/judge0:latest
    command: [ "./scripts/workers" ]
    volumes:
      - ./judge0.conf:/judge0.conf:ro
    privileged: true
    <<: *default-logging
    restart: always

  db:
    image: postgres:13.0
    env_file: judge0.conf
    volumes:
      - postgres-data:/var/lib/postgresql/data/
    <<: *default-logging
    restart: always

  redis:
    image: redis:6.0
    command:
      [
        "bash",
        "-c",
        'docker-entrypoint.sh --appendonly yes --requirepass "$$REDIS_PASSWORD"'
      ]
    env_file: judge0.conf
    volumes:
      - redis-data:/data
    <<: *default-logging
    restart: always

volumes:
  postgres-data:
  redis-data:

As you can see, there’re 2 interesting services: flask_app, worker.

In flask_app service’s /app/app.py, route / in the main logic of the web application:

@app.route('/', methods=['GET', 'POST'])
def index() -> Response:
    """The main endpoint"""
    if request.method == 'GET':
        with open(__file__) as f:
            source_code = f.read()
        return render_template('index.html', banned_words=BANNED_WORDS, source_code=source_code)
    args = request.form
    code = args.get('code', '')

    if len(code) == 0:
        flash('Code cannot be empty', ERROR)
        return redirect('/')

    # Sanitize code
    code = sanitize(code)
    if len(code) == 0:
        return redirect('/')

    # Send to judge0
    token = send_code_to_execute(code)
    if len(token) == 0:
        flash('An unexpected error occurred', ERROR)
        return redirect('/')

    flash("Sucessfully sent", SUCCESS)
    return redirect(f"/view/{token}")

When the code GET parameter is not empty, it’ll first run function sanitize():

def sanitize(code: str) -> str:
    """Sanitize code"""
    for word in BANNED_WORDS:
        if word in code:
            flash(f'Banned word detected: "{word}"', ERROR)
            return ''
    return code

/app/constants.py:

[...]
BANNED_WORDS = [
    'os',
    'eval',
    'exec',
    'subprocess',
    'threading',
    'multiprocessing',
    'raise',
    'quit',
    'sys',
    'stdout',
    'stderr',
    'x',
]

Basically it’s checking any BANNED_WORDS in our code’s value.

After sanitized, it’ll call function send_code_to_execute():

    # Send to judge0
    token = send_code_to_execute(code)
[...]
def send_code_to_execute(code: str) -> str:
    """Send code to judge0 to execute"""
    b64_code = b64encode(code.encode()).decode()
    with requests.Session() as s:
        resp = s.post(JUDGE0_SUBMIT_URL, data={
            'source_code': b64_code,
            'language_id': 71,  # Python3
            'stdin': '',
        })
    return resp.json().get('token', '')

Judge0 is a robust, scalable, and open-source online code execution system that can be used to build a wide range of applications that need online code execution features. Some examples include competitive programming platforms, e-learning platforms, candidate assessment and recruitment platforms, online code editors, online IDEs, and many more. (From https://judge0.com/)

Then, in route /view/<path:path>, we see this:

@app.route('/view/<path:path>', methods=['GET'])
def view_code(path: str) -> Response:
    """View the submitted code"""
    view_url = f'{JUDGE0_BASE_URL}/submissions/{path}/?base64_encoded=true'
    with requests.Session() as session:
        resp = session.get(view_url)
        data = resp.json()

    for key in DECODE_KEYS:
        if data.get(key, False):
            data[key] = b64decode(data[key]).decode()

    status = data.get('status', {}).get('id', 0)
    message = data.get('message', '')
    stderr = data.get('stderr', '')

    if status == 11 and message == "Exited with error status 139" and 'Segmentation fault' in stderr:
        flash(f"Congrats, you got the flag: {FLAG}!", SUCCESS)
    return render_template('results.html', **data)

With that said, this challenge’s aim is NOT about gaining Remote Code Execution (RCE), as the worker service (Judge0) is an isolated Docker container. Even if we gain RCE, we can’t read the flag.

So, our goal is to crash the Python programe with segmentation fault (segfault).

However, instead of Googling “How to crash a Python programe with segmentation fault”, I wanna try to use LLM (Large Language Model) like ChatGPT:

Let’s give it a shot!

import ctypes
ctypes.string_at(0)

Lmao, it worked!

Fun fact: CVE-2021-3177 is Python 3.x through 3.9.1 has a buffer overflow in PyCArg_repr in _ctypes/callproc.c, which may lead to remote code execution in certain Python applications that accept floating-point numbers as untrusted input, as demonstrated by a 1e300 argument to c_double.from_param. This occurs because sprintf is used unsafely. (From https://nvd.nist.gov/vuln/detail/CVE-2021-3177)

Conclusion

What we’ve learned:

  1. Crashing Python With Segmentation Fault Via ctypes Library