Skip to content

Commit f9746b0

Browse files
Fix: Remediate DOM Clobbering vulnerability in IAST shim (#8)
This commit fixes a critical DOM Clobbering vulnerability that allowed a target application to disable the IAST analyzer by overwriting its global callback functions. The following changes have been implemented: 1. **Randomized Callback Names:** The Go backend now generates unique, session-specific names for the `sink_event`, `execution_proof`, and `shim_error` callbacks by appending a UUID. This prevents attackers from predicting the function names. 2. **Dynamic Probe Generation:** XSS probes are now generated dynamically to use the session-specific `execution_proof` callback name, ensuring payloads trigger the correct, non-clobberable function. 3. **Defense-in-Depth JS Closure:** The JavaScript shim has been hardened to immediately capture the exposed callback functions into a local closure upon initialization. All internal reporting now uses these captured references, making the shim immune to DOM Clobbering that occurs after it has loaded. 4. **Updated Tests:** All relevant unit tests have been updated to accommodate the dynamic nature of the callback names, ensuring the test suite passes and validates the new behavior. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 3c9ab58 commit f9746b0

4 files changed

Lines changed: 87 additions & 22 deletions

File tree

internal/analysis/active/taint/probes.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ var ValidTaintFlows = map[TaintFlowPath]bool{
6565
// Pollution, and OAST-based checks. This list serves as the default configuration
6666
// for the taint analyzer.
6767
func DefaultProbes() []ProbeDefinition {
68-
// Define the execution proof function call.
69-
executionProofCall := `window.` + JSCallbackExecutionProof + `('{{.Canary}}')`
68+
// VULN-FIX: Use a template placeholder for the callback name in the execution proof.
69+
// This will be replaced by the session-specific randomized name in the analyzer.
70+
executionProofCall := `window.{{.ProofCallbackName}}('{{.Canary}}')`
7071

7172
// OAST Integration: Define the OAST callback formats.
7273
// {{.OASTServer}} is replaced by the Analyzer if an OAST provider is configured.

internal/analysis/active/taint/taint_analyzer.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ type Analyzer struct {
5757
producersWG sync.WaitGroup
5858
backgroundCtx context.Context
5959
backgroundCancel context.CancelFunc
60+
61+
// VULN-FIX: Store session-specific, randomized callback names to prevent DOM Clobbering.
62+
jsCallbackSinkEventName string
63+
jsCallbackExecutionProofName string
64+
jsCallbackShimErrorName string
6065
}
6166

6267
// NewAnalyzer creates and initializes a new taint Analyzer instance. It reads
@@ -89,6 +94,13 @@ func NewAnalyzer(config Config, reporter ResultsReporter, oastProvider OASTProvi
8994
taskLogger.Info("No OAST provider configured; out-of-band tests will be skipped.")
9095
}
9196

97+
// VULN-FIX: Generate unique callback names for this analysis session to prevent DOM Clobbering.
98+
// This makes it impossible for a target page to predict and overwrite the callback functions.
99+
shortID := uuid.New().String()[:8]
100+
sinkCallbackName := fmt.Sprintf("%s_%s", JSCallbackSinkEvent, shortID)
101+
proofCallbackName := fmt.Sprintf("%s_%s", JSCallbackExecutionProof, shortID)
102+
errorCallbackName := fmt.Sprintf("%s_%s", JSCallbackShimError, shortID)
103+
92104
return &Analyzer{
93105
config: config,
94106
reporter: reporter,
@@ -99,6 +111,11 @@ func NewAnalyzer(config Config, reporter ResultsReporter, oastProvider OASTProvi
99111
eventsChan: make(chan Event, config.EventChannelBuffer),
100112
shimTemplate: templateContent, // <-- Store the raw string
101113
validTaintFlows: localValidTaintFlows,
114+
115+
// VULN-FIX: Store the generated unique names.
116+
jsCallbackSinkEventName: sinkCallbackName,
117+
jsCallbackExecutionProofName: proofCallbackName,
118+
jsCallbackShimErrorName: errorCallbackName,
102119
}, nil
103120
}
104121

@@ -115,7 +132,7 @@ func (a *Analyzer) UpdateTaintFlowRuleForTesting(flow TaintFlowPath, isValid boo
115132
// JavaScript instrumentation shim from a template string and a JSON configuration
116133
// for sinks. This allows the session manager to prepare the shim before the
117134
// analyzer is fully instantiated.
118-
func BuildTaintShim(templateContent string, configJSON string) (string, error) {
135+
func BuildTaintShim(templateContent string, configJSON string, sinkCallback, proofCallback, errorCallback string) (string, error) {
119136
// 1. Parse the template content passed as an argument.
120137
tmpl, err := template.New("shim").Parse(templateContent)
121138
if err != nil {
@@ -130,9 +147,9 @@ func BuildTaintShim(templateContent string, configJSON string) (string, error) {
130147
ErrorCallbackName string
131148
}{
132149
SinksJSON: configJSON,
133-
SinkCallbackName: JSCallbackSinkEvent,
134-
ProofCallbackName: JSCallbackExecutionProof,
135-
ErrorCallbackName: JSCallbackShimError,
150+
SinkCallbackName: sinkCallback,
151+
ProofCallbackName: proofCallback,
152+
ErrorCallbackName: errorCallback,
136153
}
137154

138155
// 3. Execute the template into a buffer.
@@ -268,13 +285,14 @@ func (a *Analyzer) startBackgroundWorkers() {
268285
// instrument hooks into the client side by exposing Go functions to JavaScript
269286
// and injecting the instrumentation shim.
270287
func (a *Analyzer) instrument(ctx context.Context, session SessionContext) error {
271-
if err := session.ExposeFunction(ctx, JSCallbackSinkEvent, a.handleSinkEvent); err != nil {
288+
// VULN-FIX: Use the randomized, session-specific names when exposing functions.
289+
if err := session.ExposeFunction(ctx, a.jsCallbackSinkEventName, a.handleSinkEvent); err != nil {
272290
return fmt.Errorf("failed to expose sink event callback: %w", err)
273291
}
274-
if err := session.ExposeFunction(ctx, JSCallbackExecutionProof, a.handleExecutionProof); err != nil {
292+
if err := session.ExposeFunction(ctx, a.jsCallbackExecutionProofName, a.handleExecutionProof); err != nil {
275293
return fmt.Errorf("failed to expose execution proof callback: %w", err)
276294
}
277-
if err := session.ExposeFunction(ctx, JSCallbackShimError, a.handleShimError); err != nil {
295+
if err := session.ExposeFunction(ctx, a.jsCallbackShimErrorName, a.handleShimError); err != nil {
278296
return fmt.Errorf("failed to expose shim error callback: %w", err)
279297
}
280298

@@ -300,7 +318,8 @@ func (a *Analyzer) generateShim() (string, error) {
300318
}
301319

302320
// 2. Call the exported BuildTaintShim function with the pre-loaded template string.
303-
return BuildTaintShim(a.shimTemplate, string(sinksJSON))
321+
// VULN-FIX: Pass the session-specific randomized callback names to be injected into the shim template.
322+
return BuildTaintShim(a.shimTemplate, string(sinksJSON), a.jsCallbackSinkEventName, a.jsCallbackExecutionProofName, a.jsCallbackShimErrorName)
304323
}
305324

306325
// enqueueEvent provides a safe, non blocking mechanism for sending an event to the correlation engine.
@@ -448,7 +467,13 @@ func (a *Analyzer) preparePayload(probeDef ProbeDefinition, canary string) strin
448467
return ""
449468
}
450469

451-
replacements := []string{"{{.Canary}}", canary}
470+
// VULN-FIX: Add the dynamic, session-specific proof callback name to the replacements.
471+
// This ensures that XSS probes are generated with the correct, non-clobberable callback.
472+
replacements := []string{
473+
"{{.Canary}}", canary,
474+
"{{.ProofCallbackName}}", a.jsCallbackExecutionProofName,
475+
}
476+
452477
if requiresOAST {
453478
// We can now safely access oastProvider as oastConfigured is true.
454479
oastURL := a.oastProvider.GetServerURL()

internal/analysis/active/taint/taint_analyzer_test.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,11 @@ func TestGenerateShim(t *testing.T) {
123123
require.NoError(t, err)
124124
require.NotEmpty(t, shim)
125125

126-
assert.Contains(t, shim, fmt.Sprintf(`SinkCallbackName: "%s"`, JSCallbackSinkEvent))
127-
assert.Contains(t, shim, fmt.Sprintf(`ProofCallbackName: "%s"`, JSCallbackExecutionProof))
128-
assert.Contains(t, shim, fmt.Sprintf(`ErrorCallbackName: "%s"`, JSCallbackShimError))
126+
// VULN-FIX: Assert that the shim contains the session-specific randomized names, not the static constants.
127+
assert.Contains(t, shim, fmt.Sprintf(`SinkCallbackName: "%s"`, analyzer.jsCallbackSinkEventName))
128+
assert.Contains(t, shim, fmt.Sprintf(`ProofCallbackName: "%s"`, analyzer.jsCallbackExecutionProofName))
129+
assert.Contains(t, shim, fmt.Sprintf(`ErrorCallbackName: "%s"`, analyzer.jsCallbackShimErrorName))
130+
assert.NotContains(t, shim, fmt.Sprintf(`SinkCallbackName: "%s"`, JSCallbackSinkEvent))
129131

130132
expectedSinksJSON := `[{"Name":"eval","Type":"EVAL","Setter":false,"ArgIndex":0},{"Name":"Element.prototype.innerHTML","Type":"INNER_HTML","Setter":true,"ArgIndex":0,"ConditionID":"COND_TEST"}]`
131133
assert.Contains(t, shim, "Sinks: "+expectedSinksJSON+",")
@@ -136,9 +138,17 @@ func TestInstrument_Success(t *testing.T) {
136138
mockSession := mocks.NewMockSessionContext()
137139
ctx := context.Background()
138140

139-
mockSession.On("ExposeFunction", ctx, JSCallbackSinkEvent, mock.AnythingOfType("func(taint.SinkEvent)")).Return(nil).Once()
140-
mockSession.On("ExposeFunction", ctx, JSCallbackExecutionProof, mock.AnythingOfType("func(taint.ExecutionProofEvent)")).Return(nil).Once()
141-
mockSession.On("ExposeFunction", ctx, JSCallbackShimError, mock.AnythingOfType("func(taint.ShimErrorEvent)")).Return(nil).Once()
141+
// VULN-FIX: The callback names are now randomized. The mock should expect any string that
142+
// matches the base prefix, rather than a hardcoded static value. This makes the test robust.
143+
mockSession.On("ExposeFunction", ctx, mock.MatchedBy(func(name string) bool {
144+
return strings.HasPrefix(name, JSCallbackSinkEvent)
145+
}), mock.AnythingOfType("func(taint.SinkEvent)")).Return(nil).Once()
146+
mockSession.On("ExposeFunction", ctx, mock.MatchedBy(func(name string) bool {
147+
return strings.HasPrefix(name, JSCallbackExecutionProof)
148+
}), mock.AnythingOfType("func(taint.ExecutionProofEvent)")).Return(nil).Once()
149+
mockSession.On("ExposeFunction", ctx, mock.MatchedBy(func(name string) bool {
150+
return strings.HasPrefix(name, JSCallbackShimError)
151+
}), mock.AnythingOfType("func(taint.ShimErrorEvent)")).Return(nil).Once()
142152
mockSession.On("InjectScriptPersistently", ctx, mock.AnythingOfType("string")).Return(nil).Once()
143153

144154
err := analyzer.instrument(ctx, mockSession)
@@ -153,7 +163,10 @@ func TestInstrument_Failure_ExposeFunction(t *testing.T) {
153163
ctx := context.Background()
154164

155165
expectedError := errors.New("browser connection lost")
156-
mockSession.On("ExposeFunction", ctx, JSCallbackSinkEvent, mock.Anything).Return(expectedError).Once()
166+
// VULN-FIX: Match against the randomized prefix, not the static constant.
167+
mockSession.On("ExposeFunction", ctx, mock.MatchedBy(func(name string) bool {
168+
return strings.HasPrefix(name, JSCallbackSinkEvent)
169+
}), mock.Anything).Return(expectedError).Once()
157170

158171
err := analyzer.instrument(ctx, mockSession)
159172

@@ -734,7 +747,8 @@ func TestAnalyze_HappyPath(t *testing.T) {
734747
}
735748
analyzer.probesMutex.RUnlock()
736749
require.NotEmpty(t, activeCanary)
737-
simulateCallback(t, mockSession, JSCallbackSinkEvent, SinkEvent{Type: schemas.SinkFetchURL, Value: "http://oast.example.com/" + activeCanary})
750+
// VULN-FIX: Use the dynamic callback name from the analyzer instance.
751+
simulateCallback(t, mockSession, analyzer.jsCallbackSinkEventName, SinkEvent{Type: schemas.SinkFetchURL, Value: "http://oast.example.com/" + activeCanary})
738752
reporter.On("Report", mock.MatchedBy(func(f CorrelatedFinding) bool {
739753
return f.Canary == activeCanary && f.Sink == schemas.SinkFetchURL
740754
})).Once()

internal/analysis/active/taint/taint_shim.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,24 @@
3131
log: (...args) => scope.console.log(`[Scalpel Taint Shim - ${CONTEXT_NAME}]`, ...args)
3232
};
3333

34+
// --- VULN-FIX START: Capture Callbacks in Closure ---
35+
// Immediately capture the callback functions from the global scope into a local,
36+
// closure-scoped variable. This prevents DOM Clobbering that occurs after the
37+
// shim has initialized from affecting the shim's own reporting mechanisms.
38+
const SinkCallback = scope[CONFIG.SinkCallbackName];
39+
const ProofCallback = scope[CONFIG.ProofCallbackName];
40+
const ErrorCallback = scope[CONFIG.ErrorCallbackName];
41+
42+
// Validate that the callbacks were exposed correctly. If not, the analysis is blind.
43+
// Use the raw console.error as our own ErrorCallback might be the one that's broken.
44+
if (typeof SinkCallback !== 'function' || typeof ProofCallback !== 'function' || typeof ErrorCallback !== 'function') {
45+
scope.console.error("[Scalpel] Critical Error: Backend callbacks not exposed correctly or were clobbered before shim initialization. Analysis may be ineffective.");
46+
// Abort initialization as the shim cannot function without its callbacks.
47+
return;
48+
}
49+
// --- VULN-FIX END ---
50+
51+
3452
// Predefined condition handlers for CSP compatibility.
3553
const ConditionHandlers = {
3654
'IS_STRING_ARG0': (args) => typeof args[0] === 'string',
@@ -45,7 +63,8 @@
4563
* ROBUSTNESS: Reports internal instrumentation errors back to the Go backend.
4664
*/
4765
function reportShimError(error, location, stack = null) {
48-
const callback = scope[CONFIG.ErrorCallbackName];
66+
// VULN-FIX: Use the closure-scoped `ErrorCallback` instead of re-resolving from the global scope.
67+
const callback = ErrorCallback;
4968
if (typeof callback === 'function') {
5069
// Execute asynchronously
5170
setTimeout(() => {
@@ -61,6 +80,7 @@
6180
}
6281
}, 0);
6382
} else {
83+
// This case should theoretically not be reached due to the initial check, but is kept for robustness.
6484
logger.error(`Shim Error at ${location}:`, error);
6585
}
6686
}
@@ -196,7 +216,8 @@
196216
* Reports a detected sink event to the Go backend.
197217
*/
198218
function reportSink(type, value, detail) {
199-
const callback = scope[CONFIG.SinkCallbackName];
219+
// VULN-FIX: Use the closure-scoped `SinkCallback` instead of re-resolving from the global scope.
220+
const callback = SinkCallback;
200221
if (typeof callback === 'function') {
201222
const stack = getStackTrace();
202223
// FIX: Capture page context for FP reduction.
@@ -243,13 +264,17 @@
243264
* Overrides the execution proof callback to capture stack trace.
244265
*/
245266
function initializeExecutionProofCallback() {
246-
const originalCallback = scope[CONFIG.ProofCallbackName];
267+
// VULN-FIX: Use the closure-scoped `ProofCallback` as the original function to wrap.
268+
const originalCallback = ProofCallback;
247269
if (typeof originalCallback !== 'function') {
270+
// This case should not be reached due to the initial check, but is kept for robustness.
248271
logger.error("Backend execution proof callback not exposed correctly.");
249272
return;
250273
}
251274

252275
// Replace the exposed Go function (which is a direct binding) with a JS wrapper.
276+
// This is necessary because the XSS payload can only call the global function directly.
277+
// Our wrapper adds the stack trace before calling the real (captured) callback.
253278
scope[CONFIG.ProofCallbackName] = function(canary) {
254279
const stack = getStackTrace();
255280
// FIX: Capture page context for FP reduction.

0 commit comments

Comments
 (0)