| # ModelScan 0.8.x ACE bypass via timeit.timeit — unblocked stdlib module |
|
|
| Proof of concept for the Protect AI / huntr.com Model Format Vulnerability program. |
|
|
| ## Vulnerability |
|
|
| ModelScan 0.8.8 maintains an unsafe_globals blocklist to detect dangerous pickle |
| payloads. The timeit module is completely absent from this blocklist at every |
| severity level (CRITICAL, HIGH, MEDIUM, LOW). |
| |
| timeit.timeit(stmt, setup) compiles and executes the setup argument exactly once |
| as Python code via compile() and exec() internally, then runs stmt N times for |
| timing purposes. Placing a malicious command in setup achieves single-execution |
| arbitrary code execution while stmt='pass' runs harmlessly in the background. |
| |
| The payload contains a single GLOBAL reference: ('timeit', 'timeit'). |
| This is not present in unsafe_globals. ModelScan reports clean. |
|
|
| ## 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_timeit_ace.py |
| |
| This creates poc_timeit_bypass.pkl and poc_timeit_bypass.joblib and confirms |
| that os.system() executes at deserialization time. |
|
|
| Scan with ModelScan: |
|
|
| modelscan -p poc_timeit_bypass.pkl |
| |
| Expected output: No issues found |
|
|
| The indicator file /tmp/ml_pwned_timeit was already created by the PoC script, |
| demonstrating that execution happened before the scan result was even printed. |
|
|
| ## Payload |
|
|
| Pickle protocol 4 bytecode: |
|
|
| SHORT_BINUNICODE 'timeit' MEMOIZE(0) |
| SHORT_BINUNICODE 'timeit' MEMOIZE(1) |
| STACK_GLOBAL MEMOIZE(2) <- timeit.timeit function |
| SHORT_BINUNICODE 'pass' MEMOIZE(3) <- stmt: harmless, runs 1M times |
| SHORT_BINUNICODE 'import os...' MEMOIZE(4) <- setup: executes once -> RCE |
| TUPLE2 MEMOIZE(5) |
| REDUCE <- timeit.timeit('pass', payload) |
| STOP |
| |
| Total payload size: 93 bytes. |
|
|
| ## Root Cause |
|
|
| unsafe_globals in modelscan/settings.py does not include the timeit module. |
| The blocklist was built around well-known dangerous modules (os, subprocess, |
| sys, socket, etc.) but does not account for stdlib modules that indirectly |
| execute code strings via compile()+exec(), such as timeit, doctest, and trace. |
| |
| ## Fix |
| |
| Add to unsafe_globals CRITICAL: |
|
|
| "timeit": "*", |
| |
| ## Files |
|
|
| - poc_timeit_ace.py: generates and verifies both PoC files |
| - poc_timeit_bypass.pkl: malicious pickle, passes ModelScan, executes os.system() |
| - poc_timeit_bypass.joblib: same payload with .joblib extension |
|
|
| ## Ethics |
|
|
| Payload uses a benign indicator (id > /tmp/ml_pwned_timeit). |
| Submitted exclusively through the Protect AI / huntr.com bug bounty program. |
|
|