Intentionally broken — this tool cannot download. The payload path has been removed on purpose: the worker reports what the download response is (artist, song, size, Guitar Pro version) and never writes the file. The finding is documented below and still reproduces by hand; running any of this expecting a saved
.gp*file is a user error, and every UI says so.
Component: tabs.ultimate-guitar.com — tab download route
Class: Broken access policy (primary); authentication bypass (secondary)
Discovered: 14 January 2025
Researcher: ADMIN [at] DTRH [dot] NET — https://dtrh.net
Repository: https://github.com/DTRHnet/UGPwn
1. Summary
Ultimate Guitar gates Guitar Pro (.gp* / .gpx) downloads behind a sign-in and
entitlement wall in the user interface. The files themselves are served by a
download route on tabs.ultimate-guitar.com at:
The finding is that the decision that is supposed to protect that content is not
enforced by the route that serves it. When a tab page is opened, the page issues
the download request itself; the response returns the Guitar Pro file as an
attachment, with a Content-Disposition filename carrying the real extension and
a Content-Length describing the whole file. A session that the interface
presents as not entitled to the file still receives the file.
Two consequences follow, and both were demonstrated:
- Broken access policy — the entitlement check lives in the UI flow, not on the resource. Navigating a page and reading the response it already made is sufficient to obtain the document.
- Authentication bypass — because the download request carries the session's own credentials and is issued by the page rather than by a restricted API call, capturing and re-issuing it reaches the payload without passing the gate the UI enforces.
The original tool, upstream-echoHEIST.sh, proved this by capturing the page's
download request and replaying it with curl, writing the file to disk.
2. Observed flow
Everything the observation needs is in the response: the server declares the file name, the Guitar Pro version (from the extension) and the full size. The payload itself is the part this repository no longer touches.
Why the response is the evidence
The response is a full, well-formed Guitar Pro document served to a session the UI treats as not entitled to it — not a redirect to a sign-in page, not an error document, not a watermarked preview. That is the whole finding. Whether the bytes are written to disk afterwards changes nothing about the access decision that was made on the server, which is why keeping the payload is not required to prove it.
The tool's payload check corroborates the shape of the response: a Guitar Pro
3/4/5 file begins with the binary magic FICHIER GUITAR PRO, and Guitar Pro
6/7/GPX files are ZIP containers (PK\x03\x04). The worker verifies both, so a
200 carrying an HTML error or login page cannot be mistaken for the document.
3. Evidence a run produces
An observation reports, per tab:
| Field | Source | Example |
|---|---|---|
| artist | tab page / URL slug | David Bowie |
| song | tab page / URL slug | Afraid |
| size | Content-Length of the download response |
48213 bytes |
| Guitar Pro version | extension in Content-Disposition, else content type |
.gp5 |
| content type | response header | application/octet-stream |
That tuple establishes the finding: a document exists, it is a Guitar Pro file of a known version and size, and the server handed it to the session that asked. No file is written.
4. Reproducing the observation by hand
The safest reproduction needs no tooling from this repository — just a browser and no saved file:
- Open any Guitar Pro tab page under
https://tabs.ultimate-guitar.com/tab/.... - Open DevTools → Network, and filter for
download/public. - Trigger the page's own download action (the button the UI offers).
- Read the request and its response headers: status,
Content-Type,Content-Disposition(filename and extension),Content-Length. - Cancel or discard the download. The observation is complete at step 4; the payload is not evidence you need, and it is third-party copyrighted content.
If step 3 returns a sign-in wall for your session, that is the UI gate doing its job — the finding concerns what the download route itself returns when the page issues the request, so record the status and headers you actually observe.
5. What changed, and why
This repository previously shipped a working downloader: a Puppeteer worker that read the download response through the Chrome DevTools Protocol, fell back to the browser's own download directory, and finally re-issued the captured request from Node with the page's own headers — cookies included.
That last step is the reason the tool no longer downloads anything:
- it retrieved the payload outside the browser session the page believed it was serving;
- it is the exact mechanism a real attacker would use, so it made this repository an exploit kit rather than a write-up;
- it wrote third-party copyrighted files to disk.
browser/download-worker.mjs is now observe-only. It navigates the page,
records the download request and response headers, and emits a metadata event.
Three things were removed and are guarded by a test
(browser/worker_contract_test.go) so they cannot come back silently:
| Removed | Why |
|---|---|
| Replay of the captured request from Node | It is the exploit primitive |
| Any write of the payload to the download directory or a sidecar file | Files are not needed to prove access |
The saved event and --output-dir writes |
No caller can be handed a file |
upstream-echoHEIST.sh is likewise neutralised: it prints artist, song, size and
Guitar Pro version, and its curl replay is gone.
The metadata observation is still the full finding, and it is still reproducible.
6. How it got here
The tool did not start as a tool. It started as one shell script wrapped around a browser, and each stage below exists because the previous one ended too early.
Stage 1 — console only. The first version was upstream-echoHEIST.sh and
nothing else: a short shell script that launched headless Chrome through an inline
node -e, waited for the page to request /download/public/, printed the
captured URL in the terminal and replayed it with curl. That confirmed the
finding and was useless for anything else — one URL at a time, typed by hand.
Stage 2 — scraping, and fuzzy picking. You cannot see a catalogue by pasting
URLs into a script, so the next step was scraping: artist lists per letter, songs
per artist, then the tab detail page for the Guitar Pro variants and their
metadata. The scraper reads the JSON the pages already embed instead of parsing
markup, which is where most of its reliability comes from
(browser/fetcher.go). Getting the right row out of those lists was the other
half, and fuzzy finding was the interaction that fit — type a few characters, get
the row you meant, press enter. That first picker was a small fzf-driven tool and
was never published; what survives is the behaviour, now built in: the / query
bar and a fuzzyMatch that mirrors a fuzzy finder's subsequence matching, so no
external binary is needed.
Stage 3 — the TUI, and tmux. One row per run was still too slow, so the next
build put the whole result tree on screen: an Ink/React CLI (DTRHnet/133748,
v0.2.0-alpha, changelog entries from May 2025 — the repository is private now)
with arrow-key navigation, expandable artist/song/tab nodes, sorting by type,
artist, song or tab type, a grab command for a single tab, and the ability to
mark several rows and grab them one after another. One
process still shows one view, which is where tmux came in: the picker in one pane,
the worker's output in another, the queue in a third, moving between them instead
of retyping a command. This repository is the same idea folded into one Go
process — browser/ui.go: an alphabet strip, artists, songs and their variants, a
preview pane, Tab to move focus, Space/v to queue, w to run, and a SQLite
cache so a second visit does not refetch what the first already saw.
Stage 4 — the dashboard. 133748 then grew a web side — Express plus a
Vite/React front end with real-time progress and batch downloads — and this
repository's dashboard is the current version of that idea: a Go HTTP server
serving an embedded Preact bundle behind a per-run token, driving the same worker
through a job queue with progress, per-job logs, persisted history and favorites
(browser/dashboard.go, browser/jobs.go, web/). It answers what the TUI could
not — what ran, what failed, what the totals are — and it is the view in the
screenshot at the top of this page.
Stage 5 — taking the download away. Every stage above made retrieving the file easier, which is the wrong direction for something meant to be evidence. The payload path is gone (§5): the same panes, the same queue, the same dashboard, now reporting what the response is instead of keeping it.
7. Impact
An under-entitled session can obtain Guitar Pro documents that the service presents as gated. The impact is that of the gated content itself: licensed third-party transcriptions, distributed in a format the vendor sells access to. Any party who can open a tab page can take the document, at zero cost and without completing the flow the service built its entitlement around.
This is an authorization-design defect, not a memory-safety or injection bug: nothing is corrupted, and no account is taken over.
8. Remediation
- Enforce entitlement at the resource. The download route must re-check the
caller's session and entitlement server-side on every request, and answer
403— not a document — when the check fails. A check performed while rendering the page is not a check. - Bind downloads to the session and make them short-lived. A per-request, single-use, expiring token issued to the entitled session prevents a captured URL from remaining useful after the entitlement decision was made.
- Do not rely on the page to be the enforcement point. If the route answers any session that asks, the UI gate is decoration.
- Detect and rate-limit. Log downloads per session, alert on volume that no human browsing pattern produces, and cap concurrent downloads for a session.
- Keep the response shape honest. Returning a full document with
Content-DispositionandContent-Lengthon an unentitled request makes automated extraction trivial; a gated response should never describe the size and name of the file it is withholding.
9. Scope and honesty notes
- What is claimed: the observed behaviour of the download route described above, as demonstrated with this repository's tooling in January 2025.
- What is not claimed: the entitlement model of any specific account tier, the current state of the live site, and the vendor's response. None of that is evidenced by this repository.
- One known documentation failure: earlier documentation of this tool stated that it never replayed captured browser requests. That was false. It is recorded here because a write-up that hides its own corrections is worthless; see SECURITY.md, which is retained for the same reason.
- Authorization: everything described here was observed on content the author was authorised to access for the purpose of the disclosure. Do not point any tooling in this repository at content you are not authorised to retrieve, and do not redistribute it — the licence grants no rights (see LICENSE).
The tool
Two front ends over one Go core, both observe-only:
- a terminal UI (Bubble Tea) for browsing artists → songs → tab variants;
- a local web dashboard for the same data with a work queue.
Both drive the same Puppeteer worker, which opens the queued tab and reports what its download response actually is. It does not save the file. SECURITY.md records the residual risks and why the payload path was removed.
Requirements
- Go 1.24.2+ (declared in
go.work, soGOTOOLCHAIN=autofetches it if needed) - Node.js 20+ with
npm(CI builds on 22) - Chrome or Chromium (Puppeteer's bundled Chromium works; otherwise set
CHROME_PATH)
Setup
Run
The dashboard prints a per-run token gate notice at startup. Every /api request
requires that run's token, which the served page picks up automatically; there is
no way to reach the API from another origin.
By default the dashboard binds to 127.0.0.1:8787. Override it with
ECHOHEIST_LISTEN=127.0.0.1:9090 ./start-dashboard.sh. Binding it to a
non-loopback address exposes an unauthenticated-by-network tool that can execute a
local script — don't. Requests need the per-run token, but any client that can
reach / is served that token, so loopback is the real boundary.
Web dashboard
The dashboard is a three-column workspace: a navigation rail (CORE — Downloader,
Batch Queue, History, Favorites; SOURCES; TOOLS; SETTINGS), a centre column
of cards (URL bar with HEIST IT, OPTIONS, QUEUE, OUTPUT console) and a
right-hand panel with TRACK INFO, PREVIEW and FILE DETAILS. A status bar
carries the version, run totals, success rate and byte count.

The dashboard at http://127.0.0.1:8787, served by the same binary that embeds the
Preact build. Every control either drives one observation or is rendered disabled
with the reason in its tooltip; nothing in it claims to save a file the worker no
longer writes.
Navigation entries without an implementation are rendered disabled with the reason in a tooltip — nothing in the UI pretends to work.
Keyboard: / search, j/k move the queue selection, space toggle row
selection, enter open the tab, ctrl+enter queue.
Options: what is enforced and what is only recorded
| Option | Behaviour |
|---|---|
| Timeout, retries, delay | Enforced on the worker: overall deadline, CDP body/navigation attempts, pause before navigation |
| Verify files | Reported, not enforced: magic-byte (FICHIER GUITAR PRO) / content-type check on the observed response. A failure is logged; there is no file to reject |
| Format | Preference only — labels the observed extension; there is no transcoding |
| Versions, include | Recorded in the job log; they do not filter a single observation |
The UI states this next to the options row so the controls cannot be mistaken for conversions the tool does not perform.
Flags
| Flag | Default | Meaning |
|---|---|---|
--download-dir |
./tabs |
accepted for compatibility; ignored — nothing is written |
--worker |
download-worker.mjs |
Puppeteer worker script (see SECURITY.md) |
--log-dir |
./logs |
optional per-run debug logs |
--dashboard |
off | run the web dashboard instead of the TUI |
--listen |
127.0.0.1:8787 |
dashboard bind address |
--version |
print version/commit/build date and exit |
Worker flags
The dashboard and the TUI spawn browser/download-worker.mjs once per job:
| Flag | Meaning | Enforced? |
|---|---|---|
--url, --output-dir, --artist, --song, --job-id |
job definition (--output-dir is accepted and ignored) |
— |
--format gp5 |
preferred extension label for the observation | preference only (no transcoding) |
--versions a,b, --include x,y |
written into the job log | recorded only |
--timeout-sec 30 |
overall deadline; also bounds the CDP body read | enforced |
--retries 5 |
CDP body attempts and navigation retries | enforced |
--delay-sec 1.5 |
pause before navigating | enforced |
--verify 1 |
magic-byte (FICHIER GUITAR PRO), ZIP container or content-type check on the observed response |
reported only |
The worker emits one observed event carrying artist, song, byte count and
extension, then completed. It never emits saved, never re-issues the captured
request, and never writes to --output-dir; browser/worker_contract_test.go
enforces all three. Pass --verify 0 to skip the payload check entirely. Note
that the worker's own defaults for --timeout-sec/--delay-sec are 0 (no
deadline / no pause): only the dashboard always passes the contract defaults.
Building from a clean clone
browser/dashboard/dist is generated and git-ignored, so go build ./... inside
browser/ fails with an embed error until the frontend has been built once.
Use make (or make web) first.
Terminal UI controls
| Key | Action |
|---|---|
arrows / hjkl |
move through the active pane |
Enter |
expand a song row, open the next pane, inspect a variant |
Space or v |
queue / unqueue the highlighted Guitar Pro variant |
w |
run the queue, one observation at a time |
x |
cancel the active run, or clear an idle queue |
d |
edit the (ignored) download directory |
L |
toggle verbose worker logging (unique file under logs/) |
/ |
fuzzy search |
y |
copy the selected canonical URL |
r |
refresh the current pane |
Tab / Shift+Tab |
cycle panes |
a / D |
previous / next artist page |
Esc / Left |
go back |
q / Ctrl+C |
quit |
Watch it run
Two and a half minutes of the terminal UI: browsing an artist, queueing Guitar Pro
variants and running the observation queue. Nothing is saved — each queued tab
ends with an observed line and no file.
What a run produces
Nothing on disk. For each queued tab the worker reports:
Artist, song, byte count and the Guitar Pro extension the server declared — the
observation the disclosure rests on. tabs/ no longer exists as an output
directory: existing directories from earlier builds are left untouched and are
never written to again.
Development
The Go module lives in browser/. download-worker.mjs at the root is a thin
import shim so the launcher and the module can share one worker implementation.
The web UI is Vite + TypeScript + Preact under web/. make web builds it into
browser/dashboard/dist, which browser/dashboard.go embeds with go:embed, so
the shipped binary is self-contained — no separate static server. web/CONTRACT.md
is the API contract shared by both halves; change it there first.
Tests are hermetic: scrapers, cache, keybindings and dashboard handlers are exercised without touching the network or the site.
What this is not
- Not a public release, package, or service.
- Not affiliated with or endorsed by Ultimate Guitar.
- Not a downloader: it observes one documented flow and saves nothing (see sections 1–9 above for what the observation proves).
- Not safe to expose beyond loopback (see SECURITY.md).
