Soundcheck: security review findings#18
Open
github-actions[bot] wants to merge 1 commit into
Open
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
Summary
37 findings across 4 source files — 1 Critical, 4 High, 11 Medium, 21 Low. 12 attack chains identified where findings combine to amplify impact. The most severe issues center on the proxy's inverted private-IP logic (Critical SSRF bypass), unrestricted Unix socket passthrough, and missing connection timeouts.
Findings
proxy.rs:111resolve_host()returnsOk(vec![ip])whenis_private_ip()is true for a literal IP — logic is inverted. Sandboxed Claude can reach any loopback, RFC1918, link-local, or CGNAT address by supplying a raw IP to SOCKS5/HTTP CONNECT, bypassing all DNS filtering.if is_private_ip(&ip) { return Err(...) }. If loopback is needed for specific ports, allowlist them explicitly.sandbox.rs:155(allow network-outbound (remote unix-socket))and(allow network-inbound (local unix-socket))are unconditional in the SBPL profile. Sandboxed Claude can connect to the Docker daemon socket, SSH agent, PostgreSQL, or any other user-accessible Unix socket — completely bypassing the DNS-filtering proxy.$SSH_AUTH_SOCK, 1Password agent socket). At minimum, add explicit deny rules for/var/run/docker.sockand~/.docker/run/docker.sock.proxy.rs:154TcpStream::connectand all threecopy_bidirectionalrelay calls have no timeout. No connection count cap in either accept loop (tokio::spawnis unconditional). A sandboxed process can exhaust OS file descriptors and Tokio task memory with slow or permanently open connections.TcpStream::connectintokio::time::timeout(30 s). Wrapcopy_bidirectionalwith an inactivity timeout. Add atokio::sync::Semaphore(≤256) in each accept loop.proxy.rs:312tokio::time::timeout(e.g. 10 s). Apply the same timeout to SOCKS5 negotiation.sandbox.rs:614GIT_SSH_COMMANDis constructed viaformat!with a ProxyCommand string that is evaluated by the user's shell. While the port is a typedu16today, the shell-expansion surface exists and any future introduction of a string-typed source would be a direct injection. The surrounding context's single-quote quoting is brittle.OsStringconcatenation (notformat!) or replace thencProxyCommand with a purpose-built SOCKS5-aware SSH helper that never invokes a shell.proxy.rs:70is_private_ip()does not check the NAT64 well-known prefix64:ff9b::/96(RFC 6052) or the IPv4-translated prefix::ffff:0:x.x.x.x(RFC 6145). On a NAT64 network, a DoH AAAA response of64:ff9b::169.254.169.254bypasses the private-IP filter and routes to the IPv4 link-local metadata endpoint.IpAddr::V6arm ofis_private_ip(), unwrapping the embedded IPv4 address and re-checking it. Add test cases for64:ff9b::169.254.169.254.proxy.rs:398write_all(&buf[..total])), including the client-suppliedHostheader, which can differ from the URL authority used for DNS resolution. On shared upstream servers with virtual-host routing, this enables access to virtual hosts beyond the one that was DNS-resolved.Hostheader in the buffer to match the authority component from the request-line URL.sandbox.rs:429(allow mach-register)with no(global-name ...)filter. Sandboxed Claude can register arbitrary Mach service names, enabling service-name squatting or interception of same-session Mach lookups.sandbox.rs:451(allow ipc*)grants all POSIXshm_openand SysVshmget/semget/msggetwithout restriction. Claude can create shared memory segments accessible to other user processes, providing a covert data channel that completely bypasses all network filtering.(allow ipc-posix-shm*)for the minimum needed by Node.js/Swift runtimes. Add(allow ipc-sysv-shm)separately only if confirmed necessary.sandbox.rs:452(allow user-preference*)grants read and write access to every NSUserDefaults domain, including security-sensitive system preferences (screen lock timeout, Gatekeeper, auto-login). Writes go throughcfprefsdand persist to disk.(allow user-preference-read)for all domains and(allow user-preference-write (preference-domain "<bundle-id>"))scoped only to domains Claude Code itself writes.main.rs:14log_path()builds the path from theHOMEenvironment variable viaPathBuf::joinwithout canonicalization. AHOMEvalue containing../components places the log file outside~/.claude.Component::ParentDircomponents in the parsedHOMEvalue before constructing the path, or derive home frompasswd(e.g. thedirscrate) rather than the environment.main.rs:98OpenOptions::mode(0o600)only applies toO_CREAT. Ifziplock.logalready exists (pre-created by an attacker with mode 0644),open()succeeds without altering permissions and all subsequent sensitive hostname data is written world-readable.fchmod(fd, 0o600)usinglibc::fchmod. Also create~/.claudewith explicit mode 0700 (DirBuilder::new().mode(0o700)).sandbox.rs:535find_1password_dirs()passes discovered container paths to the SBPL profile generator without callingcanonicalize(). A symlink at~/Library/Group Containers/evil.1password → /sensitive/pathcauses the profile to grantfile-write*to the symlink's target directory.std::fs::canonicalize()on each discovered container path (as is already done for--allow-path) and verify the result is still under~/Library/Group Containers.proxy.rs:479extract_host_from_url()does not strip userinfo. A URL likehttp://attacker.com@legitimate.com/pathextractsattacker.com@legitimate.comas the host, causing log spoofing and DNS-resolution/Host-header confusion.proxy.rs:193tokio::spawnunconditionally for every accepted TCP connection with no backpressure.tokio::sync::Semaphoreor atomic connection counter; reject connections above a cap (e.g. 512) with an immediate error reply.sandbox.rs:32claude_supports_auto_mode()executes the Claude binary before the sandbox is applied (the sandbox is only active afterpre_exec). A TOCTOU window exists betweenwhich::which()and the eventual sandboxed spawn; if the binary is replaced in that window, the replacement runs unsandboxed.File::openandfstatit before the version probe; save(dev, ino)and verify they match when spawning. Alternatively, document this as accepted risk.proxy.rs:367HTTP/1.1 403 Forbidden\r\n\r\nbody with no detail, matching the plain-HTTP branch (line 412). Log the full reason viatracing::warnonly.main.rs:22rotate_log_if_large()callsmetadata()(follows symlinks) thenrename()— TOCTOU window. An attacker can replaceziplock.logwith a symlink between stat and rename, causingrenameto move an attacker-chosen file.symlink_metadata()(lstat) instead ofmetadata()inrotate_log_if_large; refuse to rotate if the path is a symlink.main.rs:31~/.claudeis created withcreate_dir_allwhich respects umask (typically 0755), making the directory world-listable. Any local user can confirm ziplock is running and observe log file sizes.~/.claudewithDirBuilder::new().mode(0o700)so the directory is inaccessible to other users.sandbox.rs:33Command::output()buffers all stdout fromclaude --versioninto memory with no size limit. A malicious or corruptedclaudebinary streaming gigabytes causes OOM before the version string is parsed. The probe runs before the sandbox is applied, so the sandbox cannot contain it.parse_claude_versionneeds only the first whitespace-delimited token.dns.rs:30webpki-roots) for TLS validation with no leaf-certificate SPKI pinning. A CA mis-issuance or compromised root allows a MITM to serve crafted DNS responses that bypass the private-IP filter.dns.rs:148.8.8.8/dns.google) as a fallback attempted after Cloudflare times out.proxy.rs:428parse_host_portaccepts[evil.com]:443as valid bracket notation and extractsevil.comas the host without validating it is actually an IPv6 address. Creates misleading audit logs; a future code path treating bracket content as trusted IP literals would be silently exploitable..parse::<Ipv6Addr>().is_err()and reject non-IPv6 values.sandbox.rs:431(allow mach-per-user-lookup)with no filter grants lookup access to per-user Mach services beyond the explicit global-name allowlist, including third-party security tools and endpoint agents.sandbox.rs:453(allow system-socket)grants the ability to create raw/packet sockets (AF_RAW,PF_NDRV). A CLI tool has no legitimate need for raw socket access; all traffic should flow through the proxy.sandbox.rs:446(allow iokit*)includesiokit-open, which grants IOKit service connections to kernel drivers. The minimum needed for GPU/Metal queries isiokit-get-propertiesonly;iokit-openhas historically been the entry point for kernel escalation CVEs.(allow iokit*)with(allow iokit-get-properties). Add(allow iokit-open (iokit-user-client-class "IOAcceleratorClient"))only if Metal requires it.sandbox.rs:454(allow darwin-notification-post)permits posting to any CFNotificationCenter name system-wide, providing a low-bandwidth covert exfiltration channel to any pre-staged listener — bypassing the network proxy entirely.sandbox.rs:537find_1password_dirs()matches directory names by case-insensitive substring. Any code running outside ziplock can pre-create~/Library/Group Containers/evil.1password.exfil/, causing the next ziplock run to grant Claudefile-write*access to that path.2BUA8C4S2C.com.1password). Verify the directory contains an expected sentinel file (agent.sock) before granting access.proxy.rs:98is_blocked_ip()checksIpv4Addr::UNSPECIFIEDandIpv6Addr::UNSPECIFIEDseparately but not the IPv4-mapped form::ffff:0.0.0.0. A resolver returning::ffff:0.0.0.0for a blocked domain produces a misleading "private IP" log entry instead of "DNS-blocked domain," masking the block reason.v6.to_ipv4_mapped() == Some(Ipv4Addr::UNSPECIFIED)tois_blocked_ip()for consistency.sandbox.rs:550find_1password_dirs()probesagent.sockexistence viasock.exists(), which follows symlinks. A symlink at the socket path pointing to an arbitrary file is accepted, causing the symlinked directory to be added as a sandbox write carve-out.symlink_metadata()and verify the result is a socket (FileType::is_socket()) before accepting the path.dns.rs:36opts.attempts = 1and add a circuit-breaker watch channel to short-circuit resolution quickly when DoH has been unavailable for N consecutive queries.proxy.rs:170resolve_local_mdns()routes.localhostnames through the system resolver (mDNSResponder), bypassing Cloudflare's malware/content filter entirely. While the result is validated to be a private IP, this still allows Claude to reach any LAN host (routers, CI servers, local Docker registries) without DNS-level filtering.WARNfor every.localconnection. Consider restricting.localconnections to a specific allowlist of ports/services actually required.proxy.rs:479extract_host_from_url()acceptshttps://as a valid scheme for plain-HTTP (non-CONNECT) proxy requests, forwarding them in plaintext — silently downgrading HTTPS to HTTP without any TLS.https://target URL with a 400 error. HTTPS targets must use CONNECT.Attack Chains
Chain C1 — Critical: DNS-Proxy Oracle → Private-IP SSRF
The sandboxed Claude process first supplies hostnames to the HTTP proxy and reads the 403 response bodies (F16), which include the resolved IP address, allowing Claude to enumerate which private ranges exist. Once a target IP is identified, Claude supplies it as a literal address (e.g.
127.0.0.1:6443) to SOCKS5 or HTTP CONNECT; becauseresolve_host()inverts the private-IP check for literals (F01), the connection is forwarded directly to the local Kubernetes API, AWS metadata endpoint, or any other loopback/RFC1918 service — bypassing every DNS-based protection the proxy provides.Chain C2 — Critical: Two Parallel SSRF Bypasses Eliminate Both Proxy Layers
F01 allows sandboxed Claude to reach any loopback or RFC1918 TCP service by supplying a raw IP, defeating the DNS-filtering proxy's core SSRF protection. F02 independently provides a completely separate path: any Unix domain socket on the system (Docker daemon, SSH agent, PostgreSQL) is reachable without going through the proxy at all. Together these two findings eliminate both intended protection layers simultaneously, giving Claude unrestricted access to local TCP services via F01 and to Unix-socket-based services via F02.
Chain C3 — High: NAT64 SSRF Amplified by Block-Reason Oracle
On a NAT64 network, Claude observes the 403 response bodies (F16) to learn which DNS queries are being blocked versus allowed, then requests hostnames that it expects to resolve to NAT64-mapped addresses. Because
is_private_ip()does not recognize the64:ff9b::/96prefix (F05), a Cloudflare DoH AAAA response of64:ff9b::169.254.169.254passes the private-IP filter and the connection is forwarded through NAT64 to the underlying IPv4 metadata service. The F16 oracle guides which hostnames to try and confirms when the NAT64 bypass succeeds.Chain C4 — High: Three-Layer Resource Exhaustion (DoS)
F14 establishes that the accept loops spawn unbounded Tokio tasks. F03 ensures each spawned task can hold an outbound TCP connection open indefinitely (no connect or relay timeout). F04 adds that an HTTP task can be pinned with a single-byte-per-minute slow send, never triggering the 8 kB size cap. An adversarially prompted Claude process opens many slow connections across both proxy listeners simultaneously, exhausting the OS socket table and Tokio task memory and degrading or crashing the ziplock parent process.
Chain C5 — High: Shared-Memory + Notification Covert Exfiltration Channel
F08's bare
(allow ipc*)lets Claude create POSIX shared memory segments accessible to any co-located user process, providing a high-bandwidth data staging area invisible to the network proxy. F17's bare(allow darwin-notification-post)provides a low-overhead signaling mechanism to notify a pre-staged listener when data is ready. Together they form a complete bidirectional covert channel: data deposited into shared memory via F08 is signaled out-of-band via F17, exfiltrating information without any network connection the DNS proxy could observe.Chain C6 — High: Mach Service Registration + Shared Memory High-Bandwidth IPC
F07 (bare
mach-register) allows Claude to register arbitrary Mach port names, enabling a pre-staged cooperating process to discover and connect to a Claude-owned Mach port for rendezvous and control signaling. F08 then provides the bulk-data transport: rather than squeezing data through Mach message buffers, large payloads are exchanged via shared memory. This combination creates a high-bandwidth two-way covert channel that bypasses DNS filtering, the network proxy, and all SBPL network rules simultaneously.Chain C7 — High: Mach Name Squatting + Preference Domain Write
F09 (bare
user-preference*) lets Claude write to any NSUserDefaults domain, including security-sensitive system preferences. F07 (baremach-register) lets Claude register Mach service names that could shadow preference-related daemons for other same-session processes. A sandboxed Claude session could squat a preference service name (F07) to intercept reads from other processes while simultaneously writing attacker-controlled values to system security settings (F09), combining service impersonation with persistent system reconfiguration.Chain C8 — High: Pre-Staged Directory Name + Symlink → Arbitrary Write Carve-Out
F19 establishes that any directory under
~/Library/Group Containerswhose name contains1passwordas a substring receives afile-write*SBPL carve-out on next ziplock launch. F12 confirms that paths are inserted into the profile without canonicalization. An attacker who creates~/Library/Group Containers/evil.1password → /sensitive/pathbefore ziplock starts causes the SBPL profile to grant Claudefile-write*access to/sensitive/path— an arbitrary location outside the intended sandbox boundary.Chain C9 — High: Pre-Sandbox Binary Execution + Unrestricted Unix Socket
F15's TOCTOU window means that if the
claudebinary is replaced between the version probe and the sandboxedexec, the replacement runs completely unsandboxed. F02's unrestricted Unix socket access amplifies the blast radius: even if the replacement occurs after sandboxing, the running process can freely reach the Docker daemon socket, SSH agent, and other privileged local services without the proxy as a barrier, making any sandbox escape significantly more impactful.Chain C10 — Medium: HOME Path Traversal + Pre-Created World-Readable Log
F10 allows a controlled
HOMEvalue to place the log file at an arbitrary path. F11 ensures that if a file already exists at that path with mode 0644,OpenOptions::mode(0o600)has no effect because the mode only applies to newly created files. An attacker who controlsHOMEand pre-creates the traversal target with permissive permissions before ziplock starts receives a world-readable log containing all resolved hostnames and proxy decisions, regardless of the intended 0600 restriction.Chain C11 — Medium: Log Rotation Symlink Swap + Pre-Created World-Readable File
F20's TOCTOU between
metadata()andrename()inrotate_log_if_largeallows an attacker to swapziplock.logfor a symlink between the stat and rename calls, redirecting the rename to an attacker-chosen location. After rotation, ziplock opens the original log path for append; if a world-readable file was pre-positioned there (the precondition F11 establishes),mode(0o600)is silently ignored and all subsequent proxy decisions are written into a world-readable file, leaking every hostname Claude contacts.Chain C12 — Medium: Userinfo URL Confusion + Verbatim Host Header Forwarding
F13 establishes that
extract_host_from_url()does not strip RFC 3986 userinfo from URLs, producing a malformedattacker.com@legitimate.comhost string that logs ambiguously and confuses DNS resolution. F06 then forwards the original raw request bytes verbatim to the upstream server, including aHostheader that may differ from the confused authority used for DNS. These two findings combine to create a host-confusion primitive: the TCP connection routes to the DNS-resolved server while the forwardedHostheader directs that server to respond as a different virtual host, potentially bypassing virtual-host access controls on shared upstream infrastructure.One-line summary: The proxy's inverted private-IP logic (Critical) combined with unrestricted Unix socket passthrough (High) together eliminate both of ziplock's intended security layers; 10 additional High/Medium findings in resource exhaustion, SBPL profile hardening, and covert IPC channels further weaken the sandbox boundary.
Run
/security-cleanupto interactively apply fixes.Generated by Soundcheck