Skip to content

Harden protocol against pickle RCE with a restricted unpickler#63

Open
LeonarddeR wants to merge 4 commits into
mainfrom
security/restricted-unpickler
Open

Harden protocol against pickle RCE with a restricted unpickler#63
LeonarddeR wants to merge 4 commits into
mainfrom
security/restricted-unpickler

Conversation

@LeonarddeR

Copy link
Copy Markdown
Owner

Problem

RDAccess uses pickle as the wire format on the DVC named pipe (_pickle/_unpickle in addon/lib/protocol/__init__.py). pickle.loads on peer-controlled bytes is an arbitrary-code-execution primitive, and the pipe crosses the RDP trust boundary in both directions (client NVDA ↔ server session). Any local process able to create an RdPipe_NVDA-* pipe can also feed the client crafted pickles. This is the same concern that motivates NVDA core deprecating pickle (nvaccess/nvda#19745, milestone 2027.1) — but network-facing here, so it bites harder.

Change

Short-term mitigation that keeps the wire format and stays compatible with existing peers: _unpickle now routes through a RestrictedUnpickler whose find_class resolves only an allowlist. _pickle and the wire framing are unchanged.

Allowlisted globals (keyed by the imported class object's runtime (__module__, __qualname__), not hardcoded strings):

  • autoSettingsUtils.driverSetting.{DriverSetting, BooleanDriverSetting, NumericDriverSetting}
  • autoSettingsUtils.utils.StringParameterInfo (+ compat alias for the old driverHandler.StringParameterInfo module)
  • synthDriverHandler.VoiceInfo
  • inputCore.GlobalGestureMap
  • collections.OrderedDict
  • RDAccess's own lib.protocol.braille.BrailleInputGesture
  • Dynamic rule for speech.commands: allowed iff the resolved attribute is a class defined in that module and issubclass(obj, SpeechCommand) — so future NVDA command classes work without an add-on update, while anything else in the module is refused.

Every rejection logs the offending (module, name) and raises pickle.UnpicklingError (rejections run inside swallowed _bgExecutor futures, so silent failures would be undiagnosable).

This blocks the generic RCE gadgets (os.system, builtins.eval, arbitrary-class __reduce__). Residual risk (accepted): pickle can still instantiate allowlisted classes with attacker state and nest builtins (memory-amplification DoS is inherent to pickle). Long-term fix — removing pickle from the wire entirely (JSON + type registry, negotiated via the existing RD_ACCESS_VERSION handshake) — is left for a future major version.

Tests

New tests/test_restrictedUnpickling.py:

  • Rejection (by allowlist denial, not incidental import errors): os.system / builtins.eval __reduce__ gadgets, a non-SpeechCommand name in speech.commands, an arbitrary unknown class; plus a test that the rejection is logged and names the offending global.
  • Acceptance (round-trips real instances): speech sequence with command instances, frozenset of command classes, DriverSetting list, OrderedDict of VoiceInfo/StringParameterInfo, GlobalGestureMap, BrailleInputGesture, builtins dict.

Full suite 102/102; ruff + ty clean.

Not covered here

Manual end-to-end over a live RDP/Citrix session (speech, settings sync, braille I/O, beeps, wave files) — every unpickle site is unit-tested for (de)serialization, but the live pipe path should be validated on a real session before release.

🤖 Generated with Claude Code

LeonarddeR and others added 4 commits July 9, 2026 20:19
pickle.loads on payloads received over the DVC named pipe is an arbitrary
code execution primitive: a compromised or malicious peer can smuggle a
__reduce__ referencing os.system, builtins.eval, subprocess.*, etc. Replace
the naked pickle.loads in RemoteProtocolHandler._unpickle with a
pickle.Unpickler subclass whose find_class only resolves an allowlist of
classes that legitimately cross the wire (driver settings, voice/parameter
info, gesture maps, RDAccess's own BrailleInputGesture, and NVDA's
speech.commands hierarchy via a dynamic subclass check), rejecting and
logging everything else. This is the short-term mitigation ahead of the
planned pickle removal (nvaccess/nvda#19745).

Also update tests/_stubs.py with stub modules for the newly allowlisted
classes, and rework test_misc.py's invalidate-cache test to probe with an
allowlisted class instead of an arbitrary AutoPropertyObject subclass, which
the new allowlist now correctly rejects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The test now verifies that error-level log records not only exist but also
contain both the module and function name of the refused global, making
rejections diagnosable. Uses platform-aware module name lookup since
os.system resolves to different modules on different platforms (nt on
Windows, posix on Unix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract RestrictedUnpickler and its lazily-built allowlist out of the
protocol package __init__ into addon/lib/protocol/_restrictedUnpickling.py,
exposed via restrictedLoads(). Keeps the security-sensitive deserialization
logic self-contained and easier to audit; behaviour is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
xgettext refused to scan the file due to a raw ellipsis char in the
module docstring, breaking the pot build in CI. Replace with ASCII "etc.".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to 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.

1 participant