Skip to content

Commit 992ba41

Browse files
committed
fix: eliminate TOCTOU Race Condition in worker thread 'cwd' caching (Hackerone report #3407207)
Fixes a Time-of-Check Time-of-Use race condition in worker thread process.cwd() caching where the counter is incremented before the directory change completes, allowing workers to cache stale directory values. Description: The main thread's process.chdir() wrapper previously incremented the shared counter before calling the actual chdir(), creating a race window where workers could read the old directory but cache it with the new counter value. This caused subsequent cwd() calls to return incorrect paths until the next chdir(). Proof of Concept: attached and already reviewed and validated by Node.js security team in report: https://hackerone.com/reports/3407207. Fix: this fix reorders the operations to change the directory first, then increment the counter, ensuring workers are only notified after the directory change completes. CVSS 3.0: 3.6 (Low) - AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N Credits: Giulio Comi, Caleb Everett, Utku Yildirim
1 parent 7bd2fea commit 992ba41

File tree

2 files changed

+227
-1
lines changed

2 files changed

+227
-1
lines changed

lib/internal/worker.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ if (isMainThread) {
112112
cwdCounter = new Uint32Array(constructSharedArrayBuffer(4));
113113
const originalChdir = process.chdir;
114114
process.chdir = function(path) {
115-
AtomicsAdd(cwdCounter, 0, 1);
116115
originalChdir(path);
116+
AtomicsAdd(cwdCounter, 0, 1);
117117
};
118118
}
119119

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { Worker, isMainThread, workerData, threadName } from 'worker_threads'
2+
import fs from 'fs/promises'
3+
import path from 'path'
4+
5+
// Number of iterations for each test
6+
const n = 1000;
7+
8+
// Artificial delays to add in mock process
9+
const mockDelayMs = 1;
10+
11+
async function main() {
12+
const { barrier, mockCWD, mockCWDCount } = await setup()
13+
if (isMainThread) {
14+
console.log("Node", process.version)
15+
}
16+
test("real process", barrier, process)
17+
test("mock process", barrier, fakeProcess(mockCWDCount, mockCWD))
18+
test("mock fixed process", barrier, fixedProcess(mockCWDCount, mockCWD))
19+
}
20+
21+
function test(label: string, barrier: Barrier, testProcess: TestProcess) {
22+
let racesWon = 0;
23+
let problems = 0;
24+
25+
for (let i = 0; i < n; i++) {
26+
const expected = dir(i)
27+
let first = ''
28+
29+
barrier.wait()
30+
31+
// The race!
32+
// main thread changes directory while tester checks what the directory is
33+
if (isMainThread) {
34+
testProcess.chdir(expected)
35+
} else if (threadName === 'tester') {
36+
first = testProcess.cwd()
37+
}
38+
39+
barrier.wait()
40+
41+
// We know the main thread has completed chdir so tester cwd should equal expected,
42+
// but we think there's a TOCTOU bug. If tester won the race and checked cwd simultaneously
43+
// with chdir then cwd will continue to return the stale value.
44+
if (threadName === 'tester') {
45+
const actual = testProcess.cwd()
46+
if (first != expected) {
47+
racesWon++
48+
if (actual == first) problems++
49+
} else {
50+
if (actual != expected) throw Error("Bug in tests")
51+
}
52+
}
53+
}
54+
55+
//barrier.wait()
56+
57+
if (threadName === 'tester') {
58+
console.log("Test case:", label)
59+
const wonPercent = (racesWon * 100 / n).toFixed(2)
60+
if (racesWon == 0) {
61+
console.log(" ⚠️The tester never called cwd before main thread chdir.")
62+
console.log(" Results are inconclusive, bad luck or bad test code.")
63+
} else if (problems > 0) {
64+
const problemPercent = (problems * 100 / racesWon).toFixed(2)
65+
console.log(` ❌ cwd in worker thread reflected stale value`)
66+
console.log(` errors: ${problemPercent}% ${problems}/${racesWon}`)
67+
} else {
68+
const percent = (racesWon * 100 / n).toFixed(2)
69+
console.log(` ✅ cwd in worker thread always had expected value`)
70+
}
71+
console.log(` races won: ${wonPercent}% ${racesWon}/${n}`)
72+
}
73+
}
74+
75+
// Create directories to chdir to, start worker threads, and create shared resources.
76+
async function setup() {
77+
const shraedMemSize = Barrier.BYTES_PER_ELEMENT * 1 + Int32Array.BYTES_PER_ELEMENT * 2
78+
const sharedBuffer = isMainThread ? new SharedArrayBuffer(shraedMemSize) : workerData.sharedBuffer;
79+
let off = 0;
80+
81+
const barrier = new Barrier(sharedBuffer, off, 2);
82+
off += Barrier.BYTES_PER_ELEMENT;
83+
84+
const mockCWDCount = new Int32Array(sharedBuffer, off, 1)
85+
off += mockCWDCount.byteLength
86+
87+
const mockCWD = new Int32Array(sharedBuffer, off, 1)
88+
off += mockCWD.byteLength
89+
90+
if (isMainThread) {
91+
// Create test directories
92+
for (let i = 0; i < n; i++) {
93+
await fs.mkdir(dir(i), { recursive: true })
94+
}
95+
96+
// Spawn the worker
97+
new Worker(import.meta.filename, { workerData: { sharedBuffer }, name: 'coordinator' })
98+
new Worker(import.meta.filename, { workerData: { sharedBuffer }, name: 'tester' })
99+
}
100+
return { barrier, mockCWD, mockCWDCount }
101+
}
102+
103+
104+
// simulation of the real process
105+
function fakeProcess(mockCWDCount: Int32Array, mockCWD: Int32Array) {
106+
let cachedCwd = '';
107+
let lastCounter = 0;
108+
109+
return {
110+
chdir: (dir: string) => {
111+
const cwd = Number(path.basename(dir))
112+
113+
// Increment counter, then store dir - like node's real chdir.
114+
busyWait(Math.random() * mockDelayMs)
115+
lastCounter = Atomics.add(mockCWDCount, 0, 1) + 1;
116+
busyWait(Math.random() * mockDelayMs)
117+
Atomics.store(mockCWD, 0, cwd);
118+
},
119+
cwd: () => {
120+
busyWait(Math.random() * mockDelayMs)
121+
const currentCounter = Atomics.load(mockCWDCount, 0);
122+
if (currentCounter === lastCounter) {
123+
return cachedCwd;
124+
}
125+
busyWait(Math.random() * mockDelayMs)
126+
lastCounter = currentCounter;
127+
cachedCwd = dir(Atomics.load(mockCWD, 0))
128+
return cachedCwd;
129+
}
130+
}
131+
}
132+
133+
// same as fakeProcess but counter order changed, this should fix the bug
134+
function fixedProcess(mockCWDCount: Int32Array, mockCWD: Int32Array) {
135+
let cachedCwd = '';
136+
let lastCounter = 0;
137+
138+
return {
139+
chdir: (dir: string) => {
140+
const cwd = Number(path.basename(dir))
141+
142+
// store dir, then increment counter which should fix the bug
143+
busyWait(Math.random() * mockDelayMs)
144+
Atomics.store(mockCWD, 0, cwd);
145+
busyWait(Math.random() * mockDelayMs)
146+
lastCounter = Atomics.add(mockCWDCount, 0, 1) + 1;
147+
},
148+
cwd: () => {
149+
busyWait(Math.random() * mockDelayMs)
150+
const currentCounter = Atomics.load(mockCWDCount, 0);
151+
if (currentCounter === lastCounter) {
152+
return cachedCwd;
153+
}
154+
busyWait(Math.random() * mockDelayMs)
155+
lastCounter = currentCounter;
156+
cachedCwd = dir(Atomics.load(mockCWD, 0))
157+
return cachedCwd;
158+
}
159+
}
160+
}
161+
162+
const originalCwd = process.cwd()
163+
164+
// Return expected dir for Nth test
165+
function dir(n: number): string {
166+
return `${originalCwd}/.tmp/${n}`
167+
}
168+
169+
// block for a number of ms, including fractional sub-ms values.
170+
// this is used in the mock processes so we don't have 100% races won or problems
171+
// not necessary but it makes me more confident in the test/mock setup.
172+
function busyWait(ms: number) {
173+
const end = performance.now() + ms;
174+
do {
175+
// @ts-ignore-error
176+
Atomics.pause()
177+
} while (performance.now() < end);
178+
}
179+
180+
// class to synchronize threads
181+
// this version relies on a 'coordinator' thread that releases
182+
// the other threads, I think this makes the race more 'fair'
183+
// as neither of the racers are holding the starting gun
184+
// but you can reproduce the bug without a coordinator
185+
class Barrier {
186+
static ELEMENTS = 2
187+
static BYTES_PER_ELEMENT = Int32Array.BYTES_PER_ELEMENT * Barrier.ELEMENTS
188+
189+
count: number
190+
state: Int32Array
191+
constructor(shared: SharedArrayBuffer, off: number, count: number) {
192+
this.count = count;
193+
this.state = new Int32Array(shared, off, Barrier.ELEMENTS)
194+
}
195+
196+
wait() {
197+
// index for number of waiting threads
198+
const WAITING = 0;
199+
// index for how many times this barrier has been used
200+
const GEN = 1;
201+
202+
if (threadName === 'coordinator') {
203+
// the coordinator will reset waiting, notifying the threads
204+
// to resume
205+
while (true) {
206+
const waiting = Atomics.load(this.state, WAITING)
207+
if (waiting >= this.count) {
208+
Atomics.add(this.state, GEN, 1);
209+
Atomics.store(this.state, WAITING, 0);
210+
Atomics.notify(this.state, GEN);
211+
return;
212+
}
213+
Atomics.wait(this.state, WAITING, waiting)
214+
}
215+
} else {
216+
const counter = Atomics.load(this.state, GEN)
217+
Atomics.add(this.state, WAITING, 1);
218+
Atomics.notify(this.state, WAITING);
219+
Atomics.wait(this.state, GEN, counter);
220+
}
221+
}
222+
}
223+
224+
type TestProcess = Pick<typeof process, 'chdir' | 'cwd'>
225+
226+
await main()

0 commit comments

Comments
 (0)