Recording PoC videos with Playwright, DevTools and Xvfb
A Claude Code skill that records captioned PoC videos of a full Chrome window with DevTools on the Network tab, on an invisible Xvfb display.
A good bug report needs evidence a triager can trust without trusting me. Text and screenshots help, but the thing that consistently gets reports understood quickly is a short video of the whole attack: the victim's state before, the attacker doing the thing, the exact request that went over the wire, and the victim's state after. I used to record those by hand, and later as GIFs from a browser extension. Both were slow to make and annoying to redo when something changed.
So as part of my hackbot I built a Claude Code skill that records them for me. You write a short scenario file, run one command, and get an mp4 of a full Chrome window with DevTools docked on the Network tab, captions explaining each step, and the forged request opened with its payload fields highlighted in red. It records on an invisible display, so nothing pops up on my desktop while it runs.
What the video shows
Every video follows the same storyboard, adapted to the bug class:
- The victim logs in and we see the object that is about to change.
- The attacker logs in with their own account and shows their own state.
- The attacker opens a perfectly normal page they are allowed to use.
- Hidden fields are revealed and the forged values are typed in slowly.
- The form is submitted through the page's own button, with the attacker's own session and CSRF token.
- DevTools: the request's Payload tab, with the forged fields highlighted.
- DevTools: the Headers tab, with the status code.
- The attacker's state after, then the victim logs back in and we see the damage.
That last step is the one that matters. Showing the effect from the victim's side is what proves the bug, and it is the step people most often skip.
Where I can, I record the same scenario twice: once against the vulnerable build and once against a patched build or a control run. Same script, same steps, opposite outcome. That makes it obvious the effect comes from the bug and not from how I set up the demo.
The moving parts
There are two files.
record.sh is the wrapper. It finds a free X display number, starts Xvfb on it, optionally resets and starts a local demo server, and records the display with ffmpeg's x11grab into an H.264 mp4. Everything is cleaned up on exit.
poc_video.js is the engine. It launches real, branded Chrome through Playwright with a throwaway profile, auto-opens DevTools docked at the bottom, sets DevTools up the way I want it, and then runs the scenario through a kit of slow, human-paced helpers.
scripts/tools/poc_video/record.sh --out evidence/notes_vulnerable.mp4 \
--pre "python seed.py" \
--server "python manage.py runserver 127.0.0.1:8811 --noreload" \
-- --scenario video_scenario.js --base http://127.0.0.1:8811 \
--label "Notes 1.0" --local
One small thing that made a big difference: record.sh and the engine do a ready/go/done handshake. ffmpeg only starts once Chrome and DevTools are fully set up, and it stops before the window closes. No black lead-in, no half-drawn first seconds, nothing to trim afterwards.
Driving DevTools itself
This was the fun part. Playwright can control the page, but DevTools is a separate web app running inside Chrome, and Playwright does not give you a handle to it. The engine finds the DevTools frontend target over the Chrome DevTools Protocol, attaches to it, and evaluates code inside it. The DevTools frontend is built from ES modules, so from inside it you can import its own panels and drive them directly:
const N = await import('./panels/network/network.js');
const UI = await import('./ui/legacy/legacy.js');
// show the Network panel, tick "Preserve log", hide the overview,
// add the Method column, resize the dock...
That is how the scenario can say "open the last POST to /notes/, switch to the Payload tab, and highlight these keys". It is also fragile in the way you would expect: the module paths are internal and can move between Chrome releases. When that happens, the error points straight at it.
Writing a scenario
A scenario is a small CommonJS module. This one is a made-up notes app where the edit form trusts a hidden owner_id field:
const login = async (k, user, pw, tone) => {
await k.newSession(); // clear cookies + the Network log
await k.goto("/login/");
await k.caption(`Logging in as "${user}"`, tone);
await k.pause(1500);
await k.type("#id_username", user);
await k.type("#id_password", pw);
await k.clickNav("button[type=submit]");
};
module.exports = {
devtoolsHeight: 0.5,
async run(k) {
await login(k, "alice", "demo-pw", "victim");
await k.caption("VICTIM before: alice owns note 1", "victim");
await k.pause(5000);
await login(k, "mallory", "demo-pw", "attacker");
await k.goto("/notes/new/");
await k.revealHidden('input[name="owner_id"]');
await k.type('[name="owner_id"]', "1");
await k.clickNav("button[type=submit]");
const status = await k.net.reveal("POST", "/notes/new/", "payload");
await k.net.highlight(["owner_id"]);
await k.caption("Payload: owner_id=1 belongs to alice, not mallory", "attacker");
await k.pause(6500);
await k.net.reveal("POST", "/notes/new/", "headers-component");
await k.caption(`Server answered ${status}`, "attacker");
await k.pause(6000);
await login(k, "alice", "demo-pw", "victim");
// TODO: branch the final caption on what is actually on the page
},
};
A few conventions came out of making a lot of these:
- Slow is good. Pause 4 to 7 seconds on anything the viewer needs to read, and type at about 110 ms per character. A full storyboard ends up around two minutes. A
--speed 0.3flag gives a quick dry run. - Captions say what is on screen and why it matters. IDs, field names, who owns what. One line each.
- Captions follow the real outcome. Never hardcode "saved". Read the status code or the page and branch on it, because the same scenario runs against the patched build and must not lie there.
- A fake cursor. CDP input does not move the real X pointer, so ffmpeg records without the mouse and the kit draws a yellow dot that glides to each element before clicking.
Staying in scope, even in the browser
Most recordings run against a local copy of the affected software on 127.0.0.1, which is the default and the mode I prefer. When a bug needs the real deployment, the browser gets the same scope gate as the rest of the hackbot, and that turned out to be more involved than I expected.
- Every request goes through Playwright's
context.route. In-scope requests are logged and get the program's attribution headers. Out-of-scope page loads and API calls are aborted. Out-of-scope static assets like fonts from a CDN are allowed but logged separately. - Redirects bypass
context.route. Playwright follows redirect hops inside the browser without asking. So a second guard uses CDP'sFetchdomain per page and re-gates every hop, failing out-of-scope ones withBlockedByClient. - DevTools makes its own requests. It probes
/.well-known/appspecific/com.chrome.devtools.jsonon every navigation and fetches source maps, and those bypass both mechanisms. They are refused inside the DevTools frontend itself, since there is no command line flag that turns the probe off any more.
Every request ends up in a log file next to the video, so I can check afterwards that the only hosts touched were the ones I expected.
Check the video before you ship it
The skill has one rule I have not relaxed: never hand over a video nobody has looked at. After recording, Claude pulls a contact sheet of the whole video with ffmpeg, one timestamped frame every four seconds, and then full-size frames at the key moments, and actually looks at them.
ffmpeg -v error -y -i video.mp4 \
-vf "fps=1/4,scale=320:-1,drawtext=text='%{pts\:hms}':x=4:y=4:fontsize=20:fontcolor=yellow:box=1:boxcolor=black,tile=6x6" \
-frames:v 1 sheet.png
That catches black stretches, a caption that does not match the screen, a payload row that is not highlighted because DevTools re-rendered the pane, or a final frame that shows the wrong account.
A few safety notes
- Only accounts I own appear on screen: throwaway demo users locally, or the program's own test accounts on a live target. No other customers' data.
- The Headers tab shows session cookies. After recording against a live target, I log those sessions out so the cookies in the video are dead.
- Demo passwords live in the scenario file, which is fine for a throwaway local app. For real test accounts the recording starts from a harvested session instead.
- The video goes in the report alongside the raw HTTP evidence, not instead of it.