Repository navigation
Soundcheck: security review findings - #35
Open
github-actions[bot] wants to merge 1 commit into
Open
github-actions[bot] wants to merge 1 commit into
github-actions[bot] wants to merge 1 commit into
Conversation
Automated rewrites from the Soundcheck security review action.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security Review
Threat Model Summary
Ziplock is a macOS-only developer tool: it wraps Claude Code in an Apple Seatbelt sandbox (
sandbox_initFFI) and a localhost SOCKS5/HTTP CONNECT proxy that filters DNS via Cloudflare DoH (1.1.1.3). The only realistic adversarial actor is prompt-injected content flowing through Claude's context; the invoking user is trusted. Direct private/LAN IP connections from the proxy are intentional (ADR 001) and are not flagged.Findings
Medium
src/dns.rs:45cryptographic-failuresrustls-platform-verifiertrusts the macOS system keychain. Any MITM root CA installed via corporate MDM can terminate the TLS session tofamily.cloudflare-dns.comand inject arbitrary DNS responses, completely bypassing the malware/adult-content filter. Hard-coded Cloudflare IPs prevent IP-level redirect but are irrelevant once TLS is broken by a trusted CA.src/proxy.rs:275insecure-designresolve_system_private()callstokio::net::lookup_host()with no timeout. On a misconfigured or adversarial LAN the system resolver can stall for minutes, pinning the Tokio task and its semaphore permit.tokio::time::timeout(Duration::from_secs(3), ...).src/proxy.rs:335insecure-designhandle_socks5()has no overall handler timeout. A client (or a prompt-injected Claude) that connects and stalls after sending one SOCKS5 byte keeps a semaphore permit occupied until the TCP stack's own timeout fires (~2+ min).timeoutmatchingHEADER_READ_TIMEOUT. Also apply an idle timeout to thecopy_bidirectionaltunnel phase.src/proxy.rs:611token-smugglinghas_cl_te_conflict()correctly detects dualCL+TEbut does not strip or rejectTransfer-Encodingfrom plain-HTTP proxy requests when noContent-Lengthis present. A chunked request is forwarded verbatim without decoding; if an upstream server treats the connection as persistent, chunk boundaries control next-request framing.Connection: closeon every forwarded plain-HTTP request to eliminate persistent-connection desync risk, or decode chunked bodies before forwarding.Low
src/proxy.rs:466header-injectionString::from_utf8_lossysilently replaces non-UTF-8 bytes in the HTTP request line withU+FFFD, masking the original bytes in logs and potentially obscuring crafted input from analysis.std::str::from_utf8and return a400 Bad Requeston non-UTF-8 input, making the rejection explicit.src/proxy.rs:184insecure-designif host.len() > 253 { return Err(...); }at the top ofresolve_host().src/proxy.rs:276sensitive-disclosureresolve_system_private()issues a plaintext UDP query to the DHCP-assigned DNS server for NXDOMAIN fallback hostnames. A prompt-injected crafted hostname (e.g.data.corp.internal) is transmitted to any LAN-visible DNS server, creating a covert exfiltration channel at ~1 lookup/connection.warn!when falling back to the system resolver (making the disclosure observable). Optionally apply a character-set/length filter before callinglookup_host.src/proxy.rs:363insecure-designDEBUGlevel; the default log level produces no allowed-connection audit trail, making post-incident analysis difficult.INFOlevel; keep blocked events atWARN.src/proxy.rs:14insecure-designMAX_CONNECTIONS=256semaphore is shared across SOCKS5 and HTTP listeners with no per-source limit. Combined with M2/M3 (no timeouts), a single misbehaving process can exhaust all permits.copy_bidirectionalto reclaim permits from stalled tunnels.src/sandbox.rs:37insecure-designclaude_supports_auto_mode()calls theclaudebinary and blocks with no timeout. A stalled or replaced binary hangs ziplock indefinitely before the Seatbelt sandbox or DNS proxy are applied.recv_timeouton a spawned thread, or usetokio::time::timeoutwithtokio::process::Command.src/sandbox.rs:116injectionsanitize_sbpl_pathconverts non-UTF-8OsStrpaths viato_string_lossy, silently embeddingU+FFFD(3-byte sequence EF BF BD) inside SBPL double-quoted string literals. Apple's sandbox parser behavior for these bytes is undocumented.src/sandbox.rs:511ipc-security(allow mach-register (local-name-regex #".*"))permits sandboxed Claude to register any per-session Mach service name. A prompt-injected Claude could squat on names expected by in-session child tools (e.g.xcodebuild, Swift compiler helpers).src/sandbox.rs:601race-conditionprepare_tmpdir()usessymlink_metadata()(lstat) thenset_permissions()(chmod). A same-UID concurrent process could theoretically replace the directory with a symlink in the TOCTOU window.src/sandbox.rs:635broken-access-controlfind_1password_dirs()suffix-matches lowercased directory names (.ends_with(".1password")). A pre-created~/Library/Group Containers/attacker.1password/directory receives an SBPL write carve-out even with no 1Password installed.t/agent.sock) before granting the carve-out, or enforce the full bundle-ID prefix format with a regex.src/dns.rs:45insecure-designedns0max payload or document the accepted risk once hickory's default limit is confirmed; add a code comment.src/main.rs:240race-conditionsignal_forward()stores the child PID as a rawi32. If Claude exits and its PID is recycled before a delayed signal arrives, the signal is delivered to an unrelated process.AtomicBoolin thewaittask whenwait()returns; check it insignal_forwardbefore callingkill().Cargo.toml:10supply-chainfast-socks5 = "1"allows any1.xupdate viacargo update. Cargo.lock currently pins1.0.0with a checksum, but a CI pipeline that runscargo updatewould adopt a future malicious1.xrelease. The crate parses untrusted hostnames from Claude.fast-socks5 = "=1.0.0". Addcargo auditto CI.src/proxy.rs:355ssrffast-socks5delivers a bracketed IPv6 literal[::1]as the ATYP=0x03 domain string,host.parse::<IpAddr>()fails, falls through to DoH (NXDOMAIN), then system resolver (also fails) — resulting in an opaque connection error rather than a clear "blocked" response. Not an SSRF bypass (connection is denied), but confusing.let host = host.strip_prefix('[').and_then(|s| s.strip_suffix(']')).unwrap_or(host).src/main.rs:133insecure-local-storageO_CREATthenset_permissions(0o600)applied to the fd. There is a theoretical TOCTOU between open and chmod, but~/.claude/is created with mode0700, making other users unable to reach the file. Effectively unreachable.set_permissionson aFileis fd-based (fchmod) and race-free.Attack Chains
Chain C1 — Medium (findings M1, L11)
An MDM-deployed MITM root CA allows an on-path corporate proxy to terminate and re-originate the TLS session to
family.cloudflare-dns.com, injecting arbitrary DNS responses and completely bypassing the malware/adult-content filter. Because hickory has no response-size limit (L11), the same adversary can inject a crafted DoH reply with an abnormally large body, forcing a proportionally large buffer allocation on every DNS lookup Claude makes. The attacker walks away with full DNS filter bypass — any blocked domain (malware C2, phishing, adult content) is now reachable — plus the ability to impose memory pressure on the resolver with every query.Chain C2 — Medium (findings M2, M3, L5)
A prompt injection causes Claude to open many concurrent SOCKS5 connections to crafted
.localhostnames on an adversarial or misconfigured LAN where the system resolver stalls indefinitely. Becauseresolve_system_privatehas no timeout (M2), each Tokio task blocks forever insidelookup_hostwhile holding its semaphore permit; becausehandle_socks5has no overall handler timeout (M3), those tasks never exit. With the shared semaphore capped at 256 permits and no per-source limit (L5), 256 such stalled connections exhaust the entire pool, causing every subsequent network connection attempt by Claude to be immediately dropped — effectively denying Claude all outbound network access for the rest of the session.Chain C3 — Low (findings L2, L3)
A prompt injection causes Claude to request crafted hostnames that encode exfiltrated data as subdomains (e.g. a base64 chunk in a
.corp.internallabel). When Cloudflare DoH returns NXDOMAIN for these names,resolve_system_privateissues a plaintext UDP query to the LAN DHCP DNS server, making the full hostname visible to any on-path observer (L3). Because there is no hostname length cap (L2), the attacker can encode up to ~253 bytes per query, maximizing the bandwidth of this covert channel. An adversary who can both inject content into Claude's context and observe LAN DNS traffic can exfiltrate data silently without any outbound TCP connection appearing in the proxy logs.Summary
20 findings across 4 source files: 4 Medium, 15 Low, 1 informational. Three viable attack chains, all Medium or lower. The most significant individual finding is M1 (corporate MITM CA bypasses the entire DNS security layer); the most operationally dangerous chain is C2 (prompt-injected DoS exhausts the proxy semaphore). No Critical findings were identified. The sandbox Seatbelt profile and core proxy filtering logic are sound; the gaps are primarily around missing timeouts, missing certificate pinning for the DoH trust anchor, and a few narrow race conditions.
Generated by Soundcheck