Skip to content

Commit 91ba0a8

Browse files
Fix DOM Clobbering Vulnerability in Prototype Pollution Analyzer (#9)
Refactored the JavaScript shim and Go analyzer for the prototype pollution scanner to mitigate a DOM Clobbering vulnerability. Key changes: - The JavaScript callback function name is now randomized for each session. - The shim immediately captures the callback function in a local closure, preventing global scope pollution. - The Go analyzer was updated to generate and inject the randomized callback name. - Tests were adapted to use flexible matchers to accommodate the dynamic function names. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent f9746b0 commit 91ba0a8

3 files changed

Lines changed: 60 additions & 35 deletions

File tree

internal/analysis/active/protopollution/analyze/proto_analyzer.go

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@ import (
1818
)
1919

2020
const (
21-
ModuleName = "PrototypePollutionAnalyzer"
22-
jsCallbackName = "__scalpel_protopollution_proof"
23-
placeholderCanary = "{{SCALPEL_CANARY}}"
24-
placeholderCallback = "{{SCALPEL_CALLBACK}}"
21+
ModuleName = "PrototypePollutionAnalyzer"
22+
placeholderCanary = "{{SCALPEL_CANARY}}"
23+
placeholderCallbackName = "{{SCALPEL_CALLBACK_NAME}}"
2524
)
2625

2726
//go:embed shim.js
@@ -49,12 +48,13 @@ type PollutionProofEvent struct {
4948
// analysisContext holds all the state required for a single, concurrent analysis
5049
// of a target URL. It is created for the duration of one `Analyze` call and then discarded.
5150
type analysisContext struct {
52-
ctx context.Context
53-
session schemas.SessionContext
54-
taskID string
55-
targetURL string
56-
canary string
57-
logger *zap.Logger
51+
ctx context.Context
52+
session schemas.SessionContext
53+
taskID string
54+
targetURL string
55+
canary string
56+
callbackName string
57+
logger *zap.Logger
5858
}
5959

6060
// NewAnalyzer creates a new, reusable instance of the prototype pollution analyzer.
@@ -92,12 +92,13 @@ func (a *Analyzer) Analyze(ctx context.Context, taskID, targetURL string) error
9292

9393
// 2. Initialize an isolated context for this specific task.
9494
aCtx := &analysisContext{
95-
ctx: ctx,
96-
session: session,
97-
taskID: taskID,
98-
targetURL: targetURL,
99-
canary: "sclp_" + uuid.New().String()[:8], // Generate a unique canary for this run.
100-
logger: a.logger.With(zap.String("taskID", taskID), zap.String("target", targetURL)),
95+
ctx: ctx,
96+
session: session,
97+
taskID: taskID,
98+
targetURL: targetURL,
99+
canary: "sclp_" + uuid.New().String()[:8], // Generate a unique canary for this run.
100+
callbackName: "sclp_cb_" + uuid.New().String()[:12], // Generate a unique, randomized callback name.
101+
logger: a.logger.With(zap.String("taskID", taskID), zap.String("target", targetURL)),
101102
}
102103

103104
// 3. Instrument the browser session with our shim and callbacks.
@@ -135,11 +136,11 @@ func (a *Analyzer) Analyze(ctx context.Context, taskID, targetURL string) error
135136
func (a *Analyzer) instrumentSession(ctx context.Context, session schemas.SessionContext, aCtx *analysisContext) error {
136137
// The handler is now a method on analysisContext, which closes over the necessary state.
137138
// This avoids complex parameter passing for the callback.
138-
if err := session.ExposeFunction(ctx, jsCallbackName, aCtx.handlePollutionProof); err != nil {
139+
if err := session.ExposeFunction(ctx, aCtx.callbackName, aCtx.handlePollutionProof); err != nil {
139140
return fmt.Errorf("failed to expose proof function: %w", err)
140141
}
141142

142-
shimScript := a.generateShim(aCtx.canary)
143+
shimScript := a.generateShim(aCtx)
143144

144145
if err := session.InjectScriptPersistently(ctx, shimScript); err != nil {
145146
return fmt.Errorf("failed to inject pp shim: %w", err)
@@ -192,10 +193,10 @@ func (aCtx *analysisContext) handlePollutionProof(event PollutionProofEvent) {
192193
}
193194

194195
// generateShim prepares the JavaScript payload using fast string replacement.
195-
func (a *Analyzer) generateShim(canary string) string {
196+
func (a *Analyzer) generateShim(aCtx *analysisContext) string {
196197
shim := protoPollutionShim
197-
shim = strings.ReplaceAll(shim, placeholderCanary, canary)
198-
shim = strings.ReplaceAll(shim, placeholderCallback, jsCallbackName)
198+
shim = strings.ReplaceAll(shim, placeholderCanary, aCtx.canary)
199+
shim = strings.ReplaceAll(shim, placeholderCallbackName, aCtx.callbackName)
199200
return shim
200201
}
201202

internal/analysis/active/protopollution/analyze/proto_analyzer_test.go

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/json"
88
"errors"
99
"regexp"
10+
"strings"
1011
"sync"
1112
"testing"
1213
"time"
@@ -58,14 +59,26 @@ func TestAnalyze_FindingFound(t *testing.T) {
5859
// --- Mocks ---
5960
mockBrowserManager.On("NewAnalysisContext", ctx, cfg, schemas.Persona{}, "", "", (chan<- schemas.Finding)(nil)).Return(mockSession, nil).Once()
6061

61-
mockSession.On("ExposeFunction", ctx, jsCallbackName, mock.AnythingOfType("func(proto.PollutionProofEvent)")).Return(nil).Once()
62+
// The callback name is now randomized, so we need to capture it.
63+
var capturedCallbackName string
64+
mockSession.On("ExposeFunction", ctx, mock.MatchedBy(func(name string) bool {
65+
return strings.HasPrefix(name, "sclp_cb_")
66+
}), mock.AnythingOfType("func(proto.PollutionProofEvent)")).Return(nil).Once().Run(func(args mock.Arguments) {
67+
capturedCallbackName = args.String(1)
68+
})
6269

6370
mockSession.On("InjectScriptPersistently", ctx, mock.AnythingOfType("string")).Return(nil).Once().Run(func(args mock.Arguments) {
6471
script := args.String(1)
65-
re := regexp.MustCompile(`let pollutionCanary = '([^']+)';`)
66-
matches := re.FindStringSubmatch(script)
67-
require.Len(t, matches, 2, "Could not find canary in injected script.")
68-
capturedCanary = matches[1]
72+
canaryRe := regexp.MustCompile(`let pollutionCanary = '([^']+)';`)
73+
canaryMatches := canaryRe.FindStringSubmatch(script)
74+
require.Len(t, canaryMatches, 2, "Could not find canary in injected script.")
75+
capturedCanary = canaryMatches[1]
76+
77+
callbackRe := regexp.MustCompile(`let detectionCallbackName = '([^']+)';`)
78+
callbackMatches := callbackRe.FindStringSubmatch(script)
79+
require.Len(t, callbackMatches, 2, "Could not find callback name in injected script.")
80+
// We assert that the callback name in the script matches the one passed to ExposeFunction.
81+
assert.Equal(t, capturedCallbackName, callbackMatches[1])
6982
})
7083

7184
mockSession.On("AddFinding", ctx, mock.AnythingOfType("schemas.Finding")).Return(nil).Once().Run(func(args mock.Arguments) {
@@ -78,9 +91,10 @@ func TestAnalyze_FindingFound(t *testing.T) {
7891
go func() {
7992
time.Sleep(20 * time.Millisecond)
8093
require.NotEmpty(t, capturedCanary, "Canary was not captured before navigation simulation")
94+
require.NotEmpty(t, capturedCallbackName, "Callback name was not captured before navigation simulation")
8195

8296
// Use the mock's helper to get the function and call it.
83-
fn, ok := mockSession.GetExposedFunction(jsCallbackName)
97+
fn, ok := mockSession.GetExposedFunction(capturedCallbackName)
8498
require.True(t, ok, "Callback function was not set via ExposeFunction")
8599
callback, ok := fn.(func(PollutionProofEvent))
86100
require.True(t, ok, "Exposed function has the wrong signature")
@@ -141,7 +155,7 @@ func TestAnalyze_NoFinding(t *testing.T) {
141155
ctx := context.Background()
142156

143157
mockBrowserManager.On("NewAnalysisContext", ctx, cfg, schemas.Persona{}, "", "", (chan<- schemas.Finding)(nil)).Return(mockSession, nil)
144-
mockSession.On("ExposeFunction", ctx, jsCallbackName, mock.AnythingOfType("func(proto.PollutionProofEvent)")).Return(nil).Once()
158+
mockSession.On("ExposeFunction", ctx, mock.AnythingOfType("string"), mock.AnythingOfType("func(proto.PollutionProofEvent)")).Return(nil).Once()
145159
mockSession.On("InjectScriptPersistently", ctx, mock.AnythingOfType("string")).Return(nil)
146160
mockSession.On("Navigate", ctx, "http://clean.example.com").Return(nil)
147161
mockSession.On("Close", mock.Anything).Return(nil)
@@ -183,7 +197,7 @@ func TestAnalyze_ContextCancellation(t *testing.T) {
183197
ctx, cancel := context.WithCancel(context.Background())
184198

185199
mockBrowserManager.On("NewAnalysisContext", mock.Anything, cfg, schemas.Persona{}, "", "", (chan<- schemas.Finding)(nil)).Return(mockSession, nil)
186-
mockSession.On("ExposeFunction", mock.Anything, jsCallbackName, mock.AnythingOfType("func(proto.PollutionProofEvent)")).Return(nil)
200+
mockSession.On("ExposeFunction", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("func(proto.PollutionProofEvent)")).Return(nil)
187201
mockSession.On("InjectScriptPersistently", mock.Anything, mock.AnythingOfType("string")).Return(nil)
188202
mockSession.On("Navigate", mock.Anything, "http://slow.example.com").Return(nil).Run(func(args mock.Arguments) {
189203
go func() {
@@ -216,7 +230,7 @@ func TestAnalyze_InstrumentationError(t *testing.T) {
216230
expectedErr := errors.New("browser disconnected")
217231

218232
mockBrowserManager.On("NewAnalysisContext", ctx, cfg, schemas.Persona{}, "", "", (chan<- schemas.Finding)(nil)).Return(mockSession, nil)
219-
mockSession.On("ExposeFunction", ctx, jsCallbackName, mock.Anything).Return(expectedErr)
233+
mockSession.On("ExposeFunction", ctx, mock.AnythingOfType("string"), mock.Anything).Return(expectedErr)
220234
mockSession.On("Close", ctx).Return(nil)
221235

222236
err := analyzer.Analyze(ctx, "task-instrument-fail", "http://example.com")
@@ -237,14 +251,18 @@ func TestAnalyze_MismatchedCanary(t *testing.T) {
237251
analyzer := NewAnalyzer(logger, mockBrowserManager, cfg)
238252
ctx := context.Background()
239253

254+
var capturedCallbackName string
240255
mockBrowserManager.On("NewAnalysisContext", ctx, cfg, schemas.Persona{}, "", "", (chan<- schemas.Finding)(nil)).Return(mockSession, nil)
241-
mockSession.On("ExposeFunction", ctx, jsCallbackName, mock.AnythingOfType("func(proto.PollutionProofEvent)")).Return(nil)
256+
mockSession.On("ExposeFunction", ctx, mock.AnythingOfType("string"), mock.AnythingOfType("func(proto.PollutionProofEvent)")).Return(nil).Run(func(args mock.Arguments) {
257+
capturedCallbackName = args.String(1)
258+
})
242259
mockSession.On("InjectScriptPersistently", ctx, mock.AnythingOfType("string")).Return(nil)
243260
mockSession.On("Navigate", ctx, "http://example.com").Return(nil).Run(func(args mock.Arguments) {
244261
go func() {
245262
time.Sleep(10 * time.Millisecond)
263+
require.NotEmpty(t, capturedCallbackName)
246264
// Use the mock's helper to get the function and call it.
247-
fn, ok := mockSession.GetExposedFunction(jsCallbackName)
265+
fn, ok := mockSession.GetExposedFunction(capturedCallbackName)
248266
require.True(t, ok, "Callback function was not set via ExposeFunction")
249267
callback, ok := fn.(func(PollutionProofEvent))
250268
require.True(t, ok, "Exposed function has the wrong signature")

internal/analysis/active/protopollution/analyze/shim.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66

77
// These placeholders are replaced by the Go analyzer before injection.
88
let pollutionCanary = '{{SCALPEL_CANARY}}';
9-
let detectionCallbackName = '__scalpel_protopollution_proof';
9+
let detectionCallbackName = '{{SCALPEL_CALLBACK_NAME}}';
10+
11+
// Immediately capture the callback function into a local variable. This is the defense-in-depth
12+
// measure against DOM Clobbering. Even if an element overwrites the global `window[detectionCallbackName]`,
13+
// our shim will still hold a reference to the original function.
14+
const callback = scope[detectionCallbackName];
1015

1116
let domObserver = null;
1217

@@ -38,12 +43,13 @@
3843
* @param {string} vector - A more detailed description or the actual malicious payload.
3944
*/
4045
function notifyBackend(source, vector) {
41-
if (typeof scope[detectionCallbackName] === 'function') {
46+
// Use the locally captured callback function, not the global one.
47+
if (typeof callback === 'function') {
4248
const stack = new Error().stack;
4349
// Use setTimeout to avoid blocking the main thread.
4450
setTimeout(() => {
4551
try {
46-
scope[detectionCallbackName]({
52+
callback({
4753
source: source,
4854
canary: pollutionCanary,
4955
vector: vector || "N/A",

0 commit comments

Comments
 (0)