') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(security): refuse pickle on object-dtype decode by default (CWE-502) by willardjansen · Pull Request #58 · lebedov/msgpack-numpy · GitHub
Skip to content

fix(security): refuse pickle on object-dtype decode by default (CWE-502) - #58

Open
willardjansen wants to merge 1 commit into
lebedov:masterfrom
willardjansen:fix/cwe-502-default-deny-pickle
Open

fix(security): refuse pickle on object-dtype decode by default (CWE-502)#58
willardjansen wants to merge 1 commit into
lebedov:masterfrom
willardjansen:fix/cwe-502-default-deny-pickle

Conversation

@willardjansen

Copy link
Copy Markdown

Summary

decode() calls pickle.loads() with zero validation whenever a msgpack payload sets kind=b'O', so any caller of unpackb() / unpack() / Unpacker will execute arbitrary code embedded in the pickle stream. The pickle reduce protocol lets a 122-byte crafted .msgpack file invoke os.system, subprocess.Popen, etc. This is the unfixed vulnerability documented in #57 and partially addressed by the unmerged #52.

This PR gates the pickle path on an explicit allow_pickle kwarg, matching numpy's own np.load(allow_pickle=...) convention:

ValueBehavior
False (new default)Raises ValueError when kind=b'O' is encountered
'restricted'RestrictedUnpickler that allowlists numpy reconstruction primitives + safe Python builtins and blocks known pickle-RCE gadgets (eval, exec, getattr, __import__, …)
TrueLegacy pickle.loads — for fully trusted sources only

The kwarg is threaded through decode(), all three Unpacker.__init__ branches (msgpack < 0.4 / < 1.0 / ≥ 1.0), unpack(), and unpackb(). pack() / packb() accept and discard it for symmetry.

Compatibility

This is a breaking change for callers that round-trip object-dtype ndarrays through unpackb() without specifying allow_pickle. The fix is one keyword:

arr=msgpack.unpackb(packed, allow_pickle='restricted')

Existing object-dtype callers can migrate either to 'restricted' (recommended) or to True (explicit opt-in to the legacy unrestricted path).

Tests

The existing test_numpy_array_object opts in via allow_pickle='restricted'. Five new tests cover:

  • Default refusal raises ValueError with a message naming allow_pickle
  • allow_pickle=True restores legacy round-trip behavior
  • Hand-crafted os.system__reduce__ payload is refused with the default
  • Same payload is refused by the restricted unpickler with pickle.UnpicklingError
  • builtins.eval gadget is on the explicit block list
  • Bad allow_pickle values raise ValueError

All 36 tests pass locally on Python 3.13 / numpy 2.4.2 / msgpack 1.1.1.

Disclosure

Filed via huntr.com on 2026-04-12 and validated by huntr triage on 2026-05-29 (CWE-502, CVSS 8.8). This PR is the proposed coordinated-disclosure fix.

Closes#57. Supersedes #52 (extends with restricted-unpickler opt-in).

decode() called pickle.loads() with zero validation on attacker-controlled
data whenever a payload set kind=b'O'. Any caller of unpackb() / unpack() /
Unpacker was exposed to arbitrary code execution from a 122-byte crafted
.msgpack file — the pickle reduce protocol allows attacker payloads to
invoke os.system, subprocess.Popen, etc.
This commit gates the pickle path on an explicit allow_pickle kwarg:
* allow_pickle=False (new default) — raises ValueError on kind=b'O'
* allow_pickle='restricted' — RestrictedUnpickler that
allowlists numpy reconstruction
primitives + safe Python builtins
and blocks known pickle-RCE
gadgets (eval/exec/getattr/...)
* allow_pickle=True — legacy pickle.loads (use only with
trusted sources)
Threaded through decode(), Unpacker (all three msgpack-version branches),
unpack(), and unpackb(). pack() / packb() accept and ignore the kwarg for
symmetry.
The existing test_numpy_array_object now opts in via
allow_pickle='restricted'. Five new tests cover:
- default refusal raises ValueError
- allow_pickle=True restores legacy behavior
- hand-crafted os.system __reduce__ payload is refused by default
- same payload is refused by the restricted unpickler
- builtins.eval gadget is on the explicit block list
- bad allow_pickle values raise ValueError
Fixes the unfixed CWE-502 documented in issue lebedov#57 and extends PR lebedov#52
with a restricted-unpickler opt-in mode.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: Arbitrary Code Execution via pickle.loads in decode() when kind='O'

1 participant

@willardjansen