Detecting Prototype Poisoning
Relevant source files
The following files were used as context for generating this wiki page:- packages/poisoning/src/main.ts
- packages/poisoning/README.md
- packages/poisoning/src/internals/TrackDiffsOnGlobal.ts
- website/blog/2023-09-21-detect-prototype-pollution-automatically/index.md
- packages/poisoning/test/main.spec.ts
- packages/poisoning/package.json
- packages/poisoning/test/internals/PoisoningFreeMap.spec.ts
Prototype poisoning is a common and critical vulnerability in the JavaScript ecosystem, where attackers modify the prototypes of base objects like Object.prototype. This can lead to unexpected behavior and security flaws in unrelated parts of an application. The @fast-check/poisoning package provides utilities to automatically detect and revert such modifications during property-based testing with fast-check.
This package works by taking a snapshot of the global state when it's first imported. It then provides functions to assert that no modifications have occurred and to restore the global state to its original condition. This allows tests to detect if the code under test is vulnerable to prototype poisoning by maliciously modifying global objects.
Sources: website/blog/2023-09-21-detect-prototype-pollution-automatically/index.md, packages/poisoning/README.md, packages/poisoning/package.json
Core Components
The @fast-check/poisoning package exports two primary functions to manage and verify the state of global objects.
| Function | Description |
|---|---|
assertNoPoisoning | Asserts that the global objects known when the package was first imported have not been changed. It throws an error if a new global has appeared, a global has been removed, or a global has been altered. |
restoreGlobals | Restores the global objects to their initial state, reverting any changes that would be detected by assertNoPoisoning. This is typically used in a cleanup step like afterEach. |
Sources: packages/poisoning/src/main.ts:25-32, packages/poisoning/README.md:16-19
Configuration
The detection functions can be configured with an options object.
| Option | Type | Description |
|---|---|---|
ignoredRootRegex | RegExp | A regular expression to specify global properties that should be ignored during the poisoning check. This is useful for excluding properties added by testing frameworks or other tools. |
The following example from the test suite shows how to ignore properties added by vitest.
const options = {
ignoredRootRegex: /(__vitest_worker__|__vitest_mocker__|AbortController|Symbol\(undici\.globalDispatcher\.\d+\))/,
};
assertNoPoisoning(options);
Sources: packages/poisoning/test/main.spec.ts:4-6, packages/poisoning/src/internals/TrackDiffsOnGlobal.ts:16-18
Detection and Restoration Workflow
The package's mechanism relies on taking a snapshot of the global environment upon its initial import and then comparing against this snapshot.
The following diagram illustrates the typical workflow in a test suite.
This diagram shows the lifecycle of poisoning detection, from initial snapshot to assertion and final restoration.
Sources: packages/poisoning/src/main.ts, website/blog/2023-09-21-detect-prototype-pollution-automatically/index.md
Usage in Property-Based Tests
To effectively detect prototype pollution, @fast-check/poisoning should be integrated into property-based tests. The assertNoPoisoning function is called after the code under test has executed, and restoreGlobals is used in a cleanup hook to ensure each test run starts with a clean state.
The following sequence diagram shows the interactions during a fast-check property test.
Example Test Case
Here is an example demonstrating how to find a prototype pollution vulnerability (like CVE-2018-3721 in lodash) using fast-check and @fast-check/poisoning.
import fc from 'fast-check';
import { assertNoPoisoning, restoreGlobals } from '@fast-check/poisoning';
import _ from 'lodash';
test('CVE-2018-3721', () => {
fc.assert(
fc
.property(fc.object(), fc.object(), (instance, other) => {
const clone = _.cloneDeep(instance);
_.merge(clone, other);
assertNoPoisoning();
})
.afterEach(restoreGlobals),
);
});
In this test, fast-check generates objects, including ones with potentially malicious keys like __proto__. If the _.merge function pollutes a prototype, assertNoPoisoning() will detect the change and fail the test. The afterEach(restoreGlobals) ensures that any pollution is cleaned up before the next test run.
Sources: website/blog/2023-09-21-detect-prototype-pollution-automatically/index.md:47-63
Internal Implementation
Snapshotting Globals
The core of the detection mechanism is the TrackDiffsOnGlobal class. When instantiated, it immediately scans globalThis and creates a deep snapshot of all its properties and their descriptors.
The snapshotting process involves:
- Getting all property names of an object.
- For each property, getting its property descriptor.
- Recursively traversing the object graph to a certain depth, capturing the structure.
- Storing this information in a
PoisoningFreeMapto be resilient against the poisoning ofMapitself.
The diff method later compares the current state of globalThis against this stored snapshot to find additions, deletions, or modifications.
Sources: packages/poisoning/src/internals/TrackDiffsOnGlobal.ts:25-41, 75-126
Resilience to Poisoning
The @fast-check/poisoning package itself is designed to be resilient against prototype pollution. For instance, if a core method like Map.prototype.get is deleted or altered, the package can still function correctly. It achieves this by using a PoisoningFreeMap wrapper, which safely captures the original Map methods (like get, set, has, entries) and exposes them via Symbol properties. This prevents the internal logic from being affected by polluted built-ins.
The following diagram shows how PoisoningFreeMap protects access to original Map methods.
This illustrates that PoisoningFreeMap bypasses potentially polluted prototype methods by using Symbols to access the original, untampered versions.
Sources: packages/poisoning/test/internals/PoisoningFreeMap.spec.ts