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 false — config.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
- 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".
- 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.
- 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.
Problem
EventRequestToUrlbuilds the/eventwin/imp/VAST-tracking URL by interpolating the bid ID and account ID directly into afmt.Sprintftemplate, with no query escaping:https://github.com/prebid/prebid-server/blob/master/endpoints/events/event.go#L141-L145
This is inconsistent with the rest of the same function: every optional parameter (
bidder,ts,f,x,int) is correctly built viaurl.Values{}.Encode()a few lines later inoptionalParameters(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.BidIDtraces back topbsBid.Bid.ID, i.e. the rawidfield of the bid object as returned by the bidder's own HTTP response (unless per-bidder bid-ID generation is turned on, which defaults tofalse—config.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 thebid.ext.prebid.events.win/impURLs and the JSON-patchedwurlattribute)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.idhappens anywhere in the adapter/exchange response-parsing path before it reaches these functions.Impact — query parameter injection via the bid's own
idBecause
request.BidIDis inserted raw between two literal&s in the template, a bid whoseiditself contains a literal&key=valuesequence injects extra query parameters into the URL. Since the account ID (a=...) is appended after the bid ID in the same template string, an injecteda=in the bid ID wins: when the resulting URL is parsed by any standard URL parser (including PBS's own/eventhandler,checkRequiredParameter, which callsr.URL.Query().Get(parameter)— https://github.com/prebid/prebid-server/blob/master/endpoints/events/event.go#L384-L392), Go'surl.Values.Getreturns the first occurrence of a repeated key, so the attacker-supplieda=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:
Triggering scenario
bid.id) returns a bid withidset to something like"1&a=other-account&x=0".bid.ext.prebid.events.win/.imp) and, for video bids whereModifyingVastXmlAllowedis set for that bidder, the VAST<Impression>tracking pixel — all embedding this bid ID unescaped./eventendpoint parsesafrom 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(andrequest.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 throughurl.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(andAccountID, for defense in depth, though that value originates from PBS's own configuration/request rather than the bid) throughurl.QueryEscapebefore interpolation, or rewriteEventRequestToUrlto build the required parameters through the sameurl.Values{}mechanismoptionalParametersalready uses, so escaping is enforced uniformly for the whole URL rather than being split across two different code paths with different guarantees.