Mengekspos endpoint API yang menerima, mem-parse, dan mengeksekusi kode secara dinamis membuka celah Remote Code Execution (RCE) fatal jika tidak dirancang dengan benar. Evaluasi ekspresi menggunakan eval() atau exec() langsung terhadap input pengguna memberikan akses penuh ke runtime host, manipulasi memori internal via __subclasses__(), hingga eksfiltrasi filesystem.

Untuk mengamankan antarmuka interpreter—seperti yang diimplementasikan pada toy interpreter atau domain-specific language (DSL)—kita wajib menerapkan inspeksi statis lewat modul ast (Abstract Syntax Tree) sebelum eksekusi dimulai, didukung dengan pembatasan resource level sistem operasi.

Anatomi Risiko RCE pada Python Interpreter Publik

Penyerang jarang memanggil import os; os.system('sh') secara terang-terangan jika ada filter regex sederhana. Sebaliknya, teknik Python sandbox escape memanfaatkan introspeksi objek runtime Python:

# Membuka shell via introspeksi kelas tanpa memanggil kata 'import'
().__class__.__base__.__subclasses__()[137].__init__.__globals__['system']('whoami')

Filter berbasis string atau blacklist regex tidak memadai karena Python mendukung decoding hex, unicode bypass, dan dynamic attribute lookup via getattr(). Sanitasi wajib dilakukan pada level pohon sintaks (AST) dengan skema default-deny whitelist.

1. Validasi Ukuran Payload dan Sanitasi Token

Tahap pertama adalah mencegah Denial of Service (DoS) pada parser AST itu sendiri. Payload raksasa atau rekursi kurung buka tak terbatas dapat menyebabkan RecursionError atau memory exhaustion saat ast.parse() bekerja.

MAX_CODE_LENGTH = 4096  # 4 KB

def validate_payload(code: str) -> None:
    if not code or not code.strip():
        raise ValueError("Payload kode tidak boleh kosong.")
    if len(code.encode('utf-8')) > MAX_CODE_LENGTH:
        raise ValueError(f"Ukuran kode melampaui limit {MAX_CODE_LENGTH} bytes.")

2. Pembatasan Node Menggunakan ast.NodeVisitor

Modul ast bawaan Python mengurai kode menjadi token hierarkis tanpa mengeksekusinya. Subclassing ast.NodeVisitor memungkinkan kita memeriksa setiap node. Jika node tidak berada di daftar izin eksplisit, proses langsung digagalkan.

import ast

class SecurityViolation(Exception):
    pass

class SafeASTValidator(ast.NodeVisitor):
    # Whitelist node dasar untuk DSL aritmatika dan kontrol alur sederhana
    ALLOWED_NODES = {
        ast.Module, ast.Expr, ast.Assign, ast.Constant,
        ast.Name, ast.BinOp, ast.UnaryOp, ast.operator,
        ast.unaryop, ast.Add, ast.Sub, ast.Mult, ast.Div,
        ast.Compare, ast.cmpop, ast.Eq, ast.NotEq, ast.Lt, ast.Gt
    }

    # Nama atribut dan variabel terlarang untuk mencegah introspeksi
    BANNED_NAMES = {
        '__class__', '__base__', '__bases__', '__subclasses__',
        '__globals__', '__code__', '__closure__', '__import__',
        'open', 'eval', 'exec', 'compile', 'getattr', 'setattr'
    }

    def generic_visit(self, node):
        if type(node) not in self.ALLOWED_NODES:
            raise SecurityViolation(f"Node terlarang terdeteksi: {type(node).__name__}")
        super().generic_visit(node)

    def visit_Name(self, node):
        if node.id in self.BANNED_NAMES or node.id.startswith('__'):
            raise SecurityViolation(f"Akses identifier terlarang: {node.id}")
        self.generic_visit(node)

    def visit_Attribute(self, node):
        # Blokir total akses atribut objek (mencegah instance.method atau class traversal)
        raise SecurityViolation(f"Akses atribut dot (.) tidak diizinkan: {node.attr}")

    def visit_Call(self, node):
        # Blokir eksekusi fungsi langsung jika interpreter tidak membutuhkannya
        raise SecurityViolation("Pemanggilan fungsi dilarang pada sandbox ini.")

Penggunaan visit_Attribute dengan penolakan langsung menutup celah traversal dunder methods (seperti __subclasses__) yang menjadi vektor utama sandbox escape.

3. Isolasi Resource Runtime (CPU Time dan Memory Limit)

Meskipun AST lolos sanitasi, kode valid seperti 9 ** 99999999 atau while True: pass dapat membekukan worker thread/process. Kita menggunakan modul resource level kernel Linux untuk membatasi CPU time dan virtual memory (RLIMIT).

import resource
import signal

def set_process_limits(max_cpu_seconds: int = 2, max_memory_mb: int = 64):
    # Batasi waktu eksekusi CPU
    resource.setrlimit(resource.RLIMIT_CPU, (max_cpu_seconds, max_cpu_seconds))
    
    # Batasi memory heap (virtual memory)
    max_bytes = max_memory_mb * 1024 * 1024
    resource.setrlimit(resource.RLIMIT_AS, (max_bytes, max_bytes))

Catatan: Eksekusi wajib dijalankan dalam child process via multiprocessing.Process atau worker terpisah agar batasan resource tidak menghentikan master process API.

4. Implementasi API Runner dan Kontrak Respons Deterministik

API sandbox harus mengembalikan error yang terstruktur dan deterministik tanpa membocorkan internal stack trace server. Parsing error dibagi menjadi tiga kategori: SYNTAX_ERROR, SECURITY_VIOLATION, dan RUNTIME_ERROR.

import sys
import ast
from typing import Tuple, Dict, Any

def execute_sandbox(source_code: str) -> Tuple[bool, Dict[str, Any]]:
    # 1. Validasi payload awal
    try:
        validate_payload(source_code)
        tree = ast.parse(source_code)
    except SyntaxError as e:
        return False, {"error_type": "SYNTAX_ERROR", "message": str(e), "line": e.lineno}
    except ValueError as e:
        return False, {"error_type": "PAYLOAD_INVALID", "message": str(e)}

    # 2. Validasi AST Whitelist
    validator = SafeASTValidator()
    try:
        validator.visit(tree)
    except SecurityViolation as e:
        return False, {"error_type": "SECURITY_VIOLATION", "message": str(e)}

    # 3. Eksekusi terbatas dalam namespace kosong
    restricted_scope = {"__builtins__": {}}
    try:
        # ponytail: implementasikan timeout thread/subprocessing pada level worker
        compiled_code = compile(tree, filename="<sandbox>", mode="exec")
        exec(compiled_code, restricted_scope, restricted_scope)
        
        # Hapus kunci builtins dari output
        restricted_scope.pop("__builtins__", None)
        return True, {"status": "SUCCESS", "variables": restricted_scope}
    except Exception as e:
        return False, {"error_type": "RUNTIME_ERROR", "message": f"{type(e).__name__}: {str(e)}"}

Format Respons JSON Deterministik

Saat terjadi pelanggaran keamanan, respons API memberikan status kode 422 Unprocessable Content atau 400 Bad Request dengan struktur yang konsisten:

{
  "success": false,
  "error": {
    "type": "SECURITY_VIOLATION",
    "message": "Node terlarang terdeteksi: Import",
    "code": 4001
  }
}

Trade-offs dan Lapisan Pertahanan Tambahan

Membatasi AST saja di dalam interpreter Python tidak pernah cukup untuk kode untrusted berskala produksi. Python runtime didesain fleksibel, bukan terisolasi secara kriptografis.

  • Trade-off Fleksibilitas: Semakin ketat AST whitelist, semakin terbatas fungsionalitas bahasa yang dapat digunakan. Jika butuh eksekusi Python penuh (termasuk modul eksternal), sanitasi AST tidak cocok.
  • Defense in Depth: Selalu kombinasikan sanitasi AST dengan isolasi container ephemeral (misalnya Docker dengan --read-only rootfs, --network=none, dan user non-root) atau microVM seperti Firecracker dan gVisor untuk memastikan kernel host tetap aman jika terjadi zero-day breakout.