The short version
Velociraptor 0.77.1 embedded stored notebook cell input into a Go template during backup restoration. A stock analyst could plant a template action in a VQL cell; when an administrator later restored a normal backup containing that notebook, the restore renderer evaluated the action with a permissive ACL manager.
The lab demonstrated server-host command execution. This was stored, administrator-triggered escalation-not autonomous analyst RCE. The issue was fixed before 0.77.2 and is tracked as CVE-2026-19584.
At a glance
| Field | Detail |
|---|---|
| Tested version | Velociraptor 0.77.1, commit 3137c7f71 |
| Fixed version | 0.77.2 |
| Vulnerability class | Template injection / code injection, CWE-1336 and CWE-94 |
| Vendor affected range | Velociraptor on Linux before 0.77.2 |
| Required access | Stock analyst with NOTEBOOK_EDITOR |
| User interaction | Administrator restores a backup containing the stored cell |
| Demonstrated impact | Server-host command execution during restore |
| Researcher estimate | High |
| Credits | Yuval Miller and Leon Kayaliev |
Reproduction
The analyst lacked EXECVE, all collection permissions, and administrative
permissions. A direct execution attempt established the control:
SELECT * FROM execve(argv=[
'/bin/sh', '-c', 'echo ANALYST_DIRECT > /tmp/CONTROL.txt'])
Velociraptor returned an EXECVE permission error and created no file.
Using the analyst’s normal notebook permissions, I stored this complete input in a VQL cell:
{{ Query "SELECT * FROM execve(argv=['/bin/sh','-c','id > /tmp/RESTORE_PROOF.txt 2>&1; echo RESTORE_RAN_AS_SUPERUSER >> /tmp/RESTORE_PROOF.txt'])" | Table }}
Normal cell evaluation rejected the braces as invalid VQL. Nothing executed, but the input remained stored. An administrator then created a normal backup after the cell existed and restored the notebook provider from that backup:
SELECT * FROM backup(name='restore-test.zip')
SELECT * FROM backup_restore(
name='restore-test.zip',
providers='Notebook')
The restore completed and the proof file contained real server-host id
output followed by the marker. Backup creation alone did not execute the
payload; execution occurred during restore.
The attacker did not create, upload, or replace the ZIP. This matters because the vulnerable object could reach the restore path through Velociraptor’s ordinary backup workflow.
Why the backup sequence matters
There are three different moments in this chain: storing the cell, creating the backup, and restoring it. I tested them separately. Saving the cell did not execute the template action. Creating a backup containing it did not execute the action either. Only restore reached the privileged renderer.
That distinction keeps the impact honest. An analyst could prepare the stored input, but an administrator or automated recovery workflow still had to restore a backup containing it. The ZIP did not need to come from the attacker, which is what made the ordinary backup lifecycle relevant.
Root cause
The notebook restore code created its renderer with:
acl_manager := acl_managers.NullACLManager{}
It then constructed a Go template by concatenating the stored cell input:
data := "Recalculate this cell:\n```vql\n" + cell.Input + "\n```\n"
The reporting template engine exposed Query and Table helpers. Markdown
fences did not neutralize {{ ... }} because template parsing occurred before
Markdown rendering. The existing sanitizer addressed line continuations, not
template actions.
This produced a classic stored injection chain: low-privileged input was saved as data, later reinterpreted as code, and evaluated in a more privileged context.
Why the Markdown fence did not help
The vulnerable function is NotebookBackupProvider.renderCell() in
services/notebook/backup.go. It built a restore description by concatenating
cell.Input into a Go-template source string, then passed that string to the
reporting template engine. The Markdown fence was only output syntax; the
template engine parsed {{ ... }} before Markdown rendering began.
The order was:
stored notebook input
-> concatenated into Go-template source
-> template actions parsed
-> Query/Table helpers evaluated
-> resulting text rendered as Markdown
Escaping backticks, line continuations, or other Markdown characters cannot repair that data/code boundary. The template must remain static and cell input must enter the renderer as a value, never as part of its grammar.
Detection notes
Search stored notebook cell input and notebook backups for Go-template
delimiters such as {{ and }}, particularly around Query, Table, or
other reporting helpers. Treat this as a triage signal rather than proof;
legitimate documentation may contain braces.
Correlate suspicious cells with backup-restore events and server-side VQL or process execution. The restore timestamp is the important execution boundary, while the notebook edit identifies who planted the input and when.
Remediation
Upgrade to Velociraptor 0.77.2 or later. Notebook cell input should be passed to the renderer as inert data rather than concatenated into template source.
The fix in
c0c9dd60
binds the cell input to a scope variable named In and places the fixed action
{{ Scope "In" }} in the template. The attacker controls the value, not the
template grammar. The patch retains NullACLManager; removing execution
helpers or replacing that manager with a least-privilege identity would be
additional defense in depth, not a description of the vendor fix.
Disclosure timeline
| Date | Event |
|---|---|
| 2026-08-05 | Vulnerability reported to Rapid7 |
| 2026-09-10 | Rapid7 published CVE-2026-19584 and closed ticket 140166 |