GHSA-83pf-v6qq-pwmr: Fickling has a detection bypass via stdlib network-protocol constructors
Our assessment`imtplib`, `imaplib`, `ftplib`, `poplib`, `telnetlib`, and `nntplib` were added to the list of unsafe imports (https://github.com/trailofbits/fickling/commit/6d20564d23acf14b42ec883908aed159be7b9ade). The `UnusedVariables` heuristic works as expected.Original reportSummaryFickling's `check_safety()` API and `--check-safety` CLI flag incorrectly rate as
`LIKELY_SAFE` pickle files that open outbound TCP connections at deserialization time
using stdlib network-protocol constructors: `smtplib.SMTP`, `imaplib.IMAP4`,
`ftplib.FTP`, `poplib.POP3`, `telnetlib.Telnet`, and `nntplib.NNTP`.The bypass exploits two independent root causes described below.Root Cause 1: Incomplete blocklist (fixed in PR #233)`fickling/fickle.py` (lines 41-97) defines `UNSAFE_IMPORTS`, the primary blocklist.
`fickling/analysis.py` (lines 229-248) defines the parallel
`UnsafeImportsML.UNSAFE_MODULES` dict. Both omitted the following stdlib
network-protocol modules whose constructors open a TCP socket at instantiation time:| Module | Class | Default port | Constructor side-effect |
|---|---|---|---|
| `smtplib` | `SMTP` | 25 | TCP connect, reads SMTP banner, sends EHLO |
| `imaplib` | `IMAP4` | 143 | TCP connect, reads IMAP capability banner |
| `ftplib` | `FTP` | 21 | TCP connect, reads FTP welcome banner |
| `poplib` | `POP3` | 110 | TCP connect, reads POP3 greeting |
| `telnetlib` | `Telnet` | 23 | TCP connect |
| `nntplib` | `NNTP` | 119 | TCP connect, NNTP handshake |Because these module names were absent from both blocklists, `UnsafeImportsML`,
`UnsafeImports`, and `NonStandardImports` all stayed silent. All six are genuine
stdlib modules so `is_std_module()` returned `True` and `NonStandardImports` did
not fire.**Status: patched in PR #233.** The six modules have been added to `UNSAFE_IMPORTS`.Root Cause 2: Logic flaw in `unused_assignments()` at `fickle.py:1183` (unpatched)Description`unused_assignments()` in `fickling/fickle.py` (lines 1174-1204) identifies variables
that are assigned but never referenced. `UnusedVariables` analysis calls this method
and raises `SUSPICIOUS` for any unreferenced variable -- this would otherwise catch a
bare `REDUCE` opcode that stores its result without using it.The flaw is at line 1183. The method iterates over `module_body` statements and, when
it encounters the final `result = <expr>` assignment, breaks out of the loop
immediately without first walking the right-hand side expression for `Name` references:Any variable that appears only in the RHS of `result = <expr>` is therefore never
added to the `used` set and is incorrectly classified as unused.How this enables bypass suppressionWhen fickling processes a `REDUCE` opcode in isolation, it generates:Because the loop breaks before scanning `result = _var0`, `_var0` never enters
`used`. `UnusedVariables` sees `_var0` as unused and raises `SUSPICIOUS`.Adding a `BUILD` opcode with an empty dict after the `REDUCE` changes the generated
AST to:Now `_var0` appears on the RHS of `_var1 = _var0`, a statement processed before the
break, so `_var0` correctly enters `used` and `UnusedVariables` stays silent.The `__setstate__` call is excluded from `OvertlyBadEvals` because
`ASTProperties.visit_Call` places it in `calls` but not in `non_setstate_calls`
(line 562), and `OvertlyBadEvals` only iterates `non_setstate_calls`.The `SMTP(...)` call is skipped by `OvertlyBadEvals` because `_process_import` adds
`SMTP` to `likely_safe_imports` for any stdlib module (line 550), and `OvertlyBadEvals`
skips calls whose function name is in `likely_safe_imports` (lines 339-345).**Net result: zero warnings, severity `LIKELY_SAFE`.**This flaw is generic -- it applies to any module not on the blocklist, not just the
six fixed in PR #233. Any future blocklist gap can be silently exploited using the
same `REDUCE + EMPTY_DICT + BUILD` pattern as long as this flaw remains unpatched.Bypass opcode sequenceFickling's synthetic AST for this sequence (what all analysis passes inspect):No analysis rule in fickling fires on this AST.Proof of ConceptRequires only `pip install fickling`. Save as `poc.py` and run.Expected outputTested on Python 3.11.1, Windows. Not OS-specific.ImpactAn attacker distributing a malicious pickle file (e.g. a crafted ML model checkpoint)
can silently:**Enumerate victims** -- receive a TCP callback every time the pickle is loaded,
including in sandboxed environments**Exfiltrate host identity** -- victim IP, hostname (via SMTP EHLO), and service
banners are sent to the attacker's server**Probe internal services (SSRF)** -- if the victim host can reach internal SMTP
relays, IMAP stores, or FTP servers, the pickle probes those services on the
attacker's behalf**Establish a covert channel** -- protocol handshakes carry attacker-controlled
bytes through a channel fickling explicitly labels safeThe `is_likely_safe()` helper (`fickling/analysis.py:468-474`) and the `--check-safety`
CLI flag both gate on `severity == LIKELY_SAFE`. This bypass clears that gate
completely with zero warnings.Suggested fixWalk `statement.value` before the `break` so variables referenced only in the result
assignment are correctly counted as used:This is the same pattern already used for every other statement in the loop
(lines 1200-1203). All 55 non-torch tests pass with this fix applied.Affected versionsAll releases including `v0.1.7` (latest). Confirmed on latest `master` as of
2026-02-19. Root cause 1 patched in PR #233 (master only, not yet released).
Root cause 2 unpatched as of this report.ReporterAnmol Vats
CVSS v4.0: 2.3