| # ModelScan 0.8.x arbitrary file write via io.open alias bypass and NEWOBJ_EX kwargs injection |
| |
| Proof of concept for the Protect AI / huntr.com Model Format Vulnerability program. |
| |
| ## Vulnerability |
| |
| Two independent weaknesses combined into an arbitrary file write chain. |
| |
| ### Weakness 1: io.open alias bypass for builtins.open |
| |
| ModelScan blocks builtins.open at CRITICAL severity. However, io.open is the |
| identical function object (io.open is open evaluates to True in Python), and the |
| io module is entirely absent from the unsafe_globals blocklist. |
|
|
| Referencing builtins.open as io.open produces the global ('io', 'open') in the |
| pickle stream. This is not matched by the blocklist rule "builtins": ["open"]. |
| ModelScan reports clean despite calling the exact same blocked function. |
|
|
| ### Weakness 2: NEWOBJ_EX opcode enables kwargs in pickle without operator.methodcaller |
| |
| Pickle protocol 4 includes the NEWOBJ_EX opcode (0x92), which calls |
| cls.__new__(cls, *args, **kwargs) with full keyword-argument support. This opcode |
| is not covered in any known ModelScan bypass research. |
| |
| Using NEWOBJ_EX to construct functools.partial with a file= keyword argument |
| allows calling builtins.print(content, file=fileobj) at deserialization time, |
| writing arbitrary content to the opened file. |
| |
| builtins.print is not in the blocklist. Only eight specific builtins are listed: |
| eval, compile, getattr, apply, exec, open, breakpoint, __import__. |
| |
| ### Combined chain |
| |
| io.open(path, 'a') -> file_obj |
| NEWOBJ_EX( |
| functools.partial, |
| args=(builtins.print, content), |
| kwargs={file: file_obj, end: ''} |
| ) -> partial_obj |
| partial_obj() -> print(content, file=file_obj) |
| -> writes content to path |
| |
| Globals extracted by ModelScan: |
| |
| ('io', 'open') not in unsafe_globals |
| ('functools', 'partial') not in unsafe_globals |
| ('builtins', 'print') not in unsafe_globals |
| |
| ModelScan verdict: No issues found. |
| |
| ## Affected |
| |
| - Scanner: ModelScan 0.8.8 (latest as of July 2026) |
| - Extensions: .pkl .pickle .joblib .dill .dat .data |
| - Trigger: pickle.loads() and joblib.load() |
| |
| ## Reproduction |
| |
| Install requirements: |
| |
| pip install modelscan joblib |
| |
| Generate the PoC files: |
| |
| python3 poc_io_open_filewrite.py |
| |
| This creates poc_filewrite_bypass.pkl and poc_filewrite_bypass.joblib and |
| confirms that the target file is written at deserialization time. |
| |
| Scan with ModelScan: |
| |
| modelscan -p poc_filewrite_bypass.pkl |
| |
| Expected output: No issues found |
| |
| ## Attack scenarios |
| |
| Writing to ~/.ssh/authorized_keys with an attacker public key gives persistent |
| SSH access. Writing to /etc/cron.d/ (as root) gives scheduled code execution. |
| Writing to ~/.bashrc or ~/.profile gives code execution on the next shell session. |
| Replace WRITE_TARGET and WRITE_CONTENT in the PoC script with the desired path |
| and content. |
| |
| ## Payload structure |
| |
| io.open(path, 'a') -> file_obj [memo 6] |
| builtins.print -> print_func [memo 9] |
| functools.partial -> partial_cls [memo 12] |
| NEWOBJ_EX(partial_cls, |
| (print_func, content), |
| {file: file_obj, end: ''}) -> partial_obj |
| EMPTY_TUPLE |
| REDUCE(partial_obj, ()) -> print(content, file=file_obj) -> file write |
| STOP |
| |
| ## Root Cause |
| |
| The blocklist approach has two gaps demonstrated here. |
| |
| First, it matches function references by module name and attribute name, but does |
| not account for the same function being accessible under multiple module paths. |
| builtins.open and io.open refer to the same underlying C function but only the |
| former is blocked. |
| |
| Second, the NEWOBJ_EX opcode (protocol 4) is not analyzed for kwargs-based |
| dangerous construction. It enables creating functools.partial with arbitrary |
| keyword arguments including file handles, bypassing the requirement for |
| operator.methodcaller or operator.attrgetter. |
| |
| ## Fix |
| |
| Add to unsafe_globals: |
| |
| "io": ["open"], |
| "_io": ["open"], |
| "functools": "*", |
| |
| Or alternatively, build a whitelist approach rather than a denylist. |
| |
| ## Files |
| |
| - poc_io_open_filewrite.py: generates and verifies both PoC files |
| - poc_filewrite_bypass.pkl: malicious pickle, passes ModelScan, writes to disk |
| - poc_filewrite_bypass.joblib: same payload with .joblib extension |
| |
| ## Ethics |
| |
| Payload writes a benign string to /tmp/ml_filewrite_indicator. |
| Submitted exclusively through the Protect AI / huntr.com bug bounty program. |
| |