Skip to content

Event tracking URL builder does not escape bid.id, allowing query-parameter injection (e.g. account ID override) #4958

Description

@sujanchalla0510

Problem

EventRequestToUrl builds the /event win/imp/VAST-tracking URL by interpolating the bid ID and account ID directly into a fmt.Sprintf template, with no query escaping:

https://github.com/prebid/prebid-server/blob/master/endpoints/events/event.go#L141-L145

const TemplateUrl = "%v/event?t=%v&b=%v&a=%v"
...
func EventRequestToUrl(externalUrl string, request *analytics.EventRequest) string {
	s := fmt.Sprintf(TemplateUrl, externalUrl, request.Type, request.BidID, request.AccountID)
	return s + optionalParameters(request)
}

This is inconsistent with the rest of the same function: every optional parameter (bidder, ts, f, x, int) is correctly built via url.Values{}.Encode() a few lines later in optionalParameters (https://github.com/prebid/prebid-server/blob/master/endpoints/events/event.go#L233-L273), which properly percent-encodes special characters including & and =. Only the required parameters — critically, request.BidID — skip this and go in raw.

request.BidID traces back to pbsBid.Bid.ID, i.e. the raw id field of the bid object as returned by the bidder's own HTTP response (unless per-bidder bid-ID generation is turned on, which defaults to falseconfig.SetDefault("generate_bid_id", false), https://github.com/prebid/prebid-server/blob/master/config/config.go#L1269 — so this is the default code path, not an edge case):

https://github.com/prebid/prebid-server/blob/master/exchange/events.go#L117-L131 (makeEventURL, used for both the bid.ext.prebid.events.win/imp URLs and the JSON-patched wurl attribute)
https://github.com/prebid/prebid-server/blob/master/exchange/events.go#L67-L80 (modifyBidVAST, injects the same unescaped bid ID into VAST <Impression> tracking URLs for video bids)

No sanitization of bid.id happens anywhere in the adapter/exchange response-parsing path before it reaches these functions.

Impact — query parameter injection via the bid's own id

Because request.BidID is inserted raw between two literal &s in the template, a bid whose id itself contains a literal &key=value sequence injects extra query parameters into the URL. Since the account ID (a=...) is appended after the bid ID in the same template string, an injected a= in the bid ID wins: when the resulting URL is parsed by any standard URL parser (including PBS's own /event handler, checkRequiredParameter, which calls r.URL.Query().Get(parameter)https://github.com/prebid/prebid-server/blob/master/endpoints/events/event.go#L384-L392), Go's url.Values.Get returns the first occurrence of a repeated key, so the attacker-supplied a= value is used, not the real, trailing one.

I confirmed this precisely with a minimal standalone reproduction of the exact template and parse logic used here:

const TemplateUrl = "%v/event?t=%v&b=%v&a=%v"
s := fmt.Sprintf(TemplateUrl, "https://pbs.example.com", "win", "1&a=attacker-account&x=0", "real-publisher-account")
// s == "https://pbs.example.com/event?t=win&b=1&a=attacker-account&x=0&a=real-publisher-account"
u, _ := url.Parse(s)
u.Query().Get("a") // => "attacker-account"  (not "real-publisher-account")
u.Query().Get("x") // => "0"                 (attacker also suppressed analytics logging for its own tracking hit)

Triggering scenario

  1. Run an auction where a bidder (a compromised, buggy, or simply non-conformant SSP — any of PBS's ~200 adapters just relays what the upstream endpoint returns as bid.id) returns a bid with id set to something like "1&a=other-account&x=0".
  2. PBS builds the bid's win/imp event URLs (bid.ext.prebid.events.win/.imp) and, for video bids where ModifyingVastXmlAllowed is set for that bidder, the VAST <Impression> tracking pixel — all embedding this bid ID unescaped.
  3. When the buyer's browser or video player later fires that tracking pixel, the /event endpoint parses a from the query string and gets the attacker-injected value instead of the real account the auction was run for.

Current vs expected behavior

Current: request.BidID (and request.AccountID) are interpolated into the URL template with no escaping, so a bid ID containing &/= characters can inject or override subsequent query parameters, including the account-ID attribution (a) and the analytics-enabled flag (x) of the very tracking request the bid itself will trigger.

Expected: Required parameters should be escaped the same way optional parameters already are in this file (url.QueryEscape, or by building the whole URL through url.Values{}.Encode() consistently), so no value derived from bidder-controlled input can alter which query parameters the resulting URL carries.

Suggested fix direction

Route BidID (and AccountID, for defense in depth, though that value originates from PBS's own configuration/request rather than the bid) through url.QueryEscape before interpolation, or rewrite EventRequestToUrl to build the required parameters through the same url.Values{} mechanism optionalParameters already uses, so escaping is enforced uniformly for the whole URL rather than being split across two different code paths with different guarantees.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions