Cybersecurity Leader specializing in threat hunting, penetration testing, and purple team operations. I detect adversary activity early and drive the mitigation and remediation work that closes the gap before it becomes an incident. Skilled in deploying and tuning endpoint detection and response platforms to strengthen visibility across complex environments. My expertise extends into data loss prevention, AI and LLM risk assessment, and governance mapped to frameworks including MITRE ATT&CK, MITRE ATLAS, and the OWASP LLM Top 10. I study how adversaries exploit systems, then translate those findings into the controls, policies, and risk decisions organizations need to operate securely.
Sole security professional carrying company-wide responsibility across penetration testing, AI governance, and data protection.
Owns company-wide penetration testing, AI governance, and tool-approval authority, with primary responsibility for the organization's data loss prevention program and oversight of managed detection and response operations.
Peer-reviewed contributions to the information security field.
Introduces a text steganography method that hides data within justified PDF text by exploiting the variable spacing text editors insert to remove ragged edges. The secret message is compressed with Huffman coding, then embedded by selectively replacing justification spaces with normal spaces across chosen host lines, with the scheme keyed for each use to strengthen communication security. Compared to prior text-based steganography approaches, the method embeds a higher information payload without altering the cover file's size, requires no electronic file exchange between parties, and remains recoverable even from a printed copy.
Offensive security and AI security, backed by hands-on lab work and applied engagement experience.
Original perspectives, vulnerability research, and threat hunting notes from the field, not case studies of confidential work.
Data loss prevention was built for a world where humans moved data — copying a file, attaching a document to an email, uploading to a personal drive. Every major DLP program in production today still assumes that model. But that world is gone. AI copilots now read entire mailboxes to draft a reply. Agentic tools summarize confidential documents on request. Employees paste proprietary code into public LLM chat windows without a second thought. None of this looks like the exfiltration patterns legacy DLP was designed to catch, and most organizations are only starting to notice the gap.
The problem isn't the AI tools. It's the missing foundation underneath them. You cannot protect what you haven't classified. Before any policy, any blocking rule, any endpoint control can work, an organization needs a real answer to a basic question: what is this data, and how sensitive is it? Most companies adopting AI tools today don't have that answer at scale. Labels are inconsistent, ownership is unclear, and sensitive data sits mixed in with everything else — which means AI tools reading "all available context" are, by definition, reading things they shouldn't.
Classification has to come first. Not as a compliance checkbox, but as living infrastructure — data labeled consistently at creation, ownership assigned, sensitivity tiers that actually mean something to the tools enforcing them downstream. Skip this step and every control built on top of it is guessing.
Then protection has to be layered, not singular. No single control catches everything an AI-augmented workflow can do with data. Classification tells you what matters. Endpoint policy governs what a device or application is allowed to do with it. Network and cloud monitoring catch what slips past both. Each layer exists because the others will eventually fail or be bypassed — by a misconfigured integration, a compromised account, or simply a tool doing exactly what it was asked to do with data it was never meant to see.
This is the shift security leaders need to make: DLP is no longer a tool you deploy once. It's a foundation you maintain continuously, because the definition of "movement" now includes an AI model reading, summarizing, and acting on data — not just a person sending it somewhere.
The organizations that get ahead of this aren't the ones with the most tools. They're the ones who classify first, control second, and monitor third — in that order, every time.
TL;DR
CVE-2025-2945 is a critical remote code execution vulnerability in pgAdmin 4, the most widely used open-source administration platform for PostgreSQL. An authenticated user could turn a simple boolean flag in the Query Tool into arbitrary Python code execution on the server, because the flag was being parsed with Python's eval() instead of an actual boolean check. The fix, once you see it, is a single line, but the path to that line says a lot about how RCE vulnerabilities are actually born: not from exotic deserialization chains, but from a developer reaching for a quick shortcut that happened to also be a code execution primitive.
Background
pgAdmin 4 is the de facto standard web interface for managing PostgreSQL databases, used by DBAs, developers, and platform teams to run queries, inspect schemas, and administer production data through a browser instead of a terminal. Its Query Tool is the core of that experience: a SQL editor that tracks transaction state (has this query been committed? is autocommit on?) and ships that state back and forth between the JavaScript frontend and the Python/Flask backend on every request. That transaction-state handshake is exactly where this vulnerability lived. Any interface that manages live database transactions for an authenticated user is, almost by definition, a high-value target: it sits adjacent to the data it's meant to protect, and it's usually reachable from wherever the DBA's browser is, which in production environments is often broader network access than the database itself allows.
Root Cause
The vulnerable code lived in web/pgadmin/tools/sqleditor/__init__.py, inside start_query_download_tool():
# Vulnerable (pgAdmin <= 9.1)
if key == 'query_commited':
query_commited = (
eval(value) if isinstance(value, str) else value
)
query_commited is meant to be a simple boolean: did the frontend already commit this transaction, yes or no. The frontend sends it as a string, "true" or "false", and the backend needed to turn that string into a Python bool. Instead of writing an explicit check, the code passed the string straight into eval().
This is a subtly different failure than most public writeups describe. It's not deserializing a complex object and not processing a JSON payload; it's using eval() as a lazy type-coercion shortcut, on the (false) assumption that the input would only ever be the literal string "true" or "false". Since eval() executes as Python, not as a boolean parser, any string is fair game: __import__('os').system('id') evaluates exactly as validly as True does.
The same anti-pattern appeared a second time in web/pgacloud/providers/google.py, inside the Cloud Deployment module's Google provider:
# Vulnerable
high_availability = (
'REGIONAL' if eval(args.high_availability) else 'ZONAL'
)
Same shape, same root cause: a value that should have been coerced to a boolean was evaluated as code instead. Two endpoints, one underlying mistake, made independently by different code paths, which is itself worth noting, since it suggests the pattern wasn't a one-off typo but something close to a house convention for handling stringly-typed booleans.
The fix, shipped in pgAdmin 9.2, replaced eval() with an actual string comparison in both places:
# Fixed (pgAdmin 9.2+)
query_commited = (
value.lower() in ('true', '1') if isinstance(value, str) else value
)
No parser, no library, no complexity, just doing the boolean check the code should have done from the start.
Exploitation Logic
The vulnerability is authenticated: an attacker needs a valid pgAdmin session before reaching the vulnerable code path. That requirement matters for accurate risk assessment: this is not a pre-auth RCE, and its CVSS 9.9 score reflects the severity of impact, not ease of unauthenticated access. Given how routinely admin interfaces end up secured with weak or default credentials, though, "authenticated" is a much lower bar in practice than it sounds on paper.
Once authenticated, reaching start_query_download_tool() requires an active Query Tool transaction. pgAdmin's SQL editor is namespaced by a transaction ID, and that transaction has to be bound to a registered server and database connection before the download endpoint will process a request at all. This isn't an incidental detail; it means an attacker needs at least one database server already registered in the target pgAdmin instance. In deployments where pgAdmin is pre-configured with one or more saved server connections, which is extremely common in real environments, this precondition is trivially met.
From there, the attacker submits a POST request to /sqleditor/query_tool/download/<trans_id> with a crafted query_commited value, not "true" or "false", but a Python expression with a side effect. The backend hands that string to eval() and executes it under the privileges of the pgAdmin service process. The Cloud Deployment path follows the identical logic through /cloud/deploy and the high_availability parameter, for accounts with access to that module.
Vulnerability Class
This is CWE-95 (Eval Injection), but it's worth being precise about which flavor. The more commonly discussed eval-injection bugs involve deserializing attacker-controlled objects (think Python pickle, or eval() used to parse what should have been JSON). This one is narrower and, in some ways, more mundane: eval() used purely as a boolean type-coercion shortcut. It's the same class of mistake that shows up whenever a developer reaches for eval() to parse a stringly-typed flag from a query string or form field instead of writing value.lower() == 'true', a one-line fix that's easy to skip past in code review because the call site looks so small.
Detection Guidance
Defenders monitoring pgAdmin deployments should watch for:
/sqleditor/query_tool/download/<trans_id> or /cloud/deploy where the query_commited or high_availability parameter is anything other than a literal "true", "false", "1", or "0".__import__, os.system, subprocess, eval, exec, open(, inside those parameter values. This is a near-certain exploitation signature; there's no legitimate reason for a boolean field to contain any of these strings./bin/sh, /bin/bash) or network utilities (nc, curl, wget): the pgAdmin process has no legitimate reason to spawn either.Remediation
eval() reached for as a coercion shortcut rarely announces itself as dangerous in code review.MITRE ATT&CK Mapping
eval() injection serves as the initial access/execution vector once authenticated.References
TL;DR
ml-logger is an unauthenticated Sanic HTTP service used to ship metrics, logs, and files between ML training jobs and a dashboard. Its /stream endpoint takes a key parameter, joins it directly onto a server-side root directory, and streams the resulting file back with no validation and no authentication in front of it. The published CVSS score of 5.3 reflects the bug in isolation, but it understates the real blast radius once chained with the sibling /glob endpoint: a single crafted request retrieved root's SSH private key directly, turning an unauthenticated HTTP call into full root compromise.
Background
ml-logger exposes a small file-broker API alongside its metrics-logging functionality: /glob to enumerate files matching a pattern, and /stream to read one back. The intent is legitimate, letting a remote training job or dashboard pull down checkpoint files, logs, or config without SSH access, but the implementation trusts client-supplied paths entirely. This is a common pattern in MLOps tooling: infrastructure built for a trusted, single-tenant research environment gets deployed as though it were a hardened multi-tenant service, without an authentication layer or a threat model that assumes an adversarial network position.
Root Cause
From the disclosed source (ml_logger/server.py):
async def stream_handler(self, req):
import sanic
if not req.json:
msg = f"request json is empty: {req.text}"
return sanic.response.text(msg)
load_entry = LoadEntry(**req.json)
path = self.abs_path(load_entry.key)
return await sanic.response.file_stream(path)
load_entry.key is attacker-controlled and passed straight into self.abs_path(), then directly into sanic.response.file_stream(), with no allow-list, no realpath containment check, and no rejection of absolute paths or traversal sequences. The maintainer's own glob_handler does reject absolute paths and traversal, but stream_handler doesn't share that validation. A doubled leading slash was enough to defeat the relative-path assumption baked into the join logic: two endpoints with inconsistent path handling, one restrictive and one not, is the actual exploitable gap, independent of either check being individually correct.
Exploitation Logic
The service typically listens on two ports: one serving the dashboard SPA (catch-all routing that returned index.html for every path, including plausible API guesses, a false negative that cost real time during recon), and one serving the actual JSON API. The backend was identified by a 405 on a GET to /glob, with the Allow header confirming POST.
Guessing JSON field names against /glob produced nothing but 500s, since the handler throws on any shape it doesn't expect. The working schema came from the public vulnerability report rather than black-box guessing:
curl -s -X POST -H "Content-Type: application/json" \
-d '{"query": "/etc/*", "wd": "/", "start": 0, "stop": 9999}' \
http://TARGET:8081/glob
This confirmed unauthenticated directory enumeration outside any intended log root. Reading the target file via /stream (a GET request carrying a JSON body) used a doubled leading slash as the key detail, a single leading slash returned empty, consistent with the join logic silently swallowing it, while a doubled one broke containment:
curl -s -X GET -H "Content-Type: application/json" \
-d '{"key":"//root/.ssh/id_rsa","type":""}' \
http://TARGET:8081/stream
The response was root's raw OpenSSH private key. Saved locally, permissions corrected, and used directly to SSH in as root, an unauthenticated HTTP request turned into an authenticated root shell with zero credentials required at any step.
Vulnerability Class
A textbook CWE-22/CWE-200 hybrid: unsanitized path input leading to arbitrary file read. This pattern is common across the MLOps tooling category, logging and metrics servers, experiment trackers, and model registries frequently expose file-serving endpoints as a convenience feature, and that convenience feature is disproportionately likely to be the least-reviewed code path in the project. The glob/stream pairing here also illustrates why per-endpoint validation isn't sufficient on its own: the glob handler's traversal check gave a false sense that the file-access surface was already covered.
Detection Guidance
.., doubled leading slashes, or absolute path prefixes like /etc or /root directed at logging or metrics service ports.Remediation
MITRE ATT&CK / ATLAS Mapping
References