Skip to content

Soundcheck: security review findings#18

Open
github-actions[bot] wants to merge 1 commit into
mainfrom
soundcheck/security-review
Open

Soundcheck: security review findings#18
github-actions[bot] wants to merge 1 commit into
mainfrom
soundcheck/security-review

Conversation

@github-actions
Copy link
Copy Markdown

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

Severity Location Skill Finding Fix
Critical proxy.rs:111 ssrf resolve_host() returns Ok(vec![ip]) when is_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. Invert the guard: if is_private_ip(&ip) { return Err(...) }. If loopback is needed for specific ports, allowlist them explicitly.
High sandbox.rs:155 ipc-security (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. Scope Unix socket rules to specific known paths ($SSH_AUTH_SOCK, 1Password agent socket). At minimum, add explicit deny rules for /var/run/docker.sock and ~/.docker/run/docker.sock.
High proxy.rs:154 model-dos TcpStream::connect and all three copy_bidirectional relay calls have no timeout. No connection count cap in either accept loop (tokio::spawn is unconditional). A sandboxed process can exhaust OS file descriptors and Tokio task memory with slow or permanently open connections. Wrap TcpStream::connect in tokio::time::timeout (30 s). Wrap copy_bidirectional with an inactivity timeout. Add a tokio::sync::Semaphore (≤256) in each accept loop.
High proxy.rs:312 model-dos HTTP proxy header read loop has an 8 kB total size cap but no read timeout. A sandboxed process can send one byte every 59 s indefinitely, holding a Tokio task open forever with no resource reclamation. Wrap the entire header-read loop in tokio::time::timeout (e.g. 10 s). Apply the same timeout to SOCKS5 negotiation.
High sandbox.rs:614 injection GIT_SSH_COMMAND is constructed via format! with a ProxyCommand string that is evaluated by the user's shell. While the port is a typed u16 today, 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. Use OsString concatenation (not format!) or replace the nc ProxyCommand with a purpose-built SOCKS5-aware SSH helper that never invokes a shell.
Medium proxy.rs:70 ssrf is_private_ip() does not check the NAT64 well-known prefix 64: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 of 64:ff9b::169.254.169.254 bypasses the private-IP filter and routes to the IPv4 link-local metadata endpoint. Add NAT64 and IPv4-translated prefix checks in the IpAddr::V6 arm of is_private_ip(), unwrapping the embedded IPv4 address and re-checking it. Add test cases for 64:ff9b::169.254.169.254.
Medium proxy.rs:398 header-injection Plain HTTP requests are forwarded verbatim (write_all(&buf[..total])), including the client-supplied Host header, 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. Before forwarding, rewrite the Host header in the buffer to match the authority component from the request-line URL.
Medium sandbox.rs:429 ipc-security Bare (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. Remove the bare rule entirely, or scope it to the specific names verified via sandbox denial logs. Most CLI tools never need to register Mach service names.
Medium sandbox.rs:451 ipc-security Bare (allow ipc*) grants all POSIX shm_open and SysV shmget/semget/msgget without restriction. Claude can create shared memory segments accessible to other user processes, providing a covert data channel that completely bypasses all network filtering. Replace with (allow ipc-posix-shm*) for the minimum needed by Node.js/Swift runtimes. Add (allow ipc-sysv-shm) separately only if confirmed necessary.
Medium sandbox.rs:452 broken-access-control Bare (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 through cfprefsd and persist to disk. Split into (allow user-preference-read) for all domains and (allow user-preference-write (preference-domain "<bundle-id>")) scoped only to domains Claude Code itself writes.
Medium main.rs:14 path-traversal log_path() builds the path from the HOME environment variable via PathBuf::join without canonicalization. A HOME value containing ../ components places the log file outside ~/.claude. Check for Component::ParentDir components in the parsed HOME value before constructing the path, or derive home from passwd (e.g. the dirs crate) rather than the environment.
Medium main.rs:98 race-condition OpenOptions::mode(0o600) only applies to O_CREAT. If ziplock.log already exists (pre-created by an attacker with mode 0644), open() succeeds without altering permissions and all subsequent sensitive hostname data is written world-readable. After opening, unconditionally fchmod(fd, 0o600) using libc::fchmod. Also create ~/.claude with explicit mode 0700 (DirBuilder::new().mode(0o700)).
Medium sandbox.rs:535 path-traversal find_1password_dirs() passes discovered container paths to the SBPL profile generator without calling canonicalize(). A symlink at ~/Library/Group Containers/evil.1password → /sensitive/path causes the profile to grant file-write* to the symlink's target directory. Call 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.
Medium proxy.rs:479 ssrf extract_host_from_url() does not strip userinfo. A URL like http://attacker.com@legitimate.com/path extracts attacker.com@legitimate.com as the host, causing log spoofing and DNS-resolution/Host-header confusion. After stripping the scheme, strip userinfo: `authority.rsplit_once('@').map(
Medium proxy.rs:193 model-dos No per-source connection rate limit: both accept loops call tokio::spawn unconditionally for every accepted TCP connection with no backpressure. Add a tokio::sync::Semaphore or atomic connection counter; reject connections above a cap (e.g. 512) with an immediate error reply.
Medium sandbox.rs:32 insecure-design claude_supports_auto_mode() executes the Claude binary before the sandbox is applied (the sandbox is only active after pre_exec). A TOCTOU window exists between which::which() and the eventual sandboxed spawn; if the binary is replaced in that window, the replacement runs unsandboxed. Open the binary with File::open and fstat it before the version probe; save (dev, ino) and verify they match when spawning. Alternatively, document this as accepted risk.
Low proxy.rs:367 sensitive-disclosure HTTP CONNECT 403 response body includes the full block reason (resolved IP, block category) and is sent to the sandboxed Claude process. This functions as a proxy oracle: Claude can enumerate which private IP ranges are reachable before using F01 to connect to them directly. Return a generic HTTP/1.1 403 Forbidden\r\n\r\n body with no detail, matching the plain-HTTP branch (line 412). Log the full reason via tracing::warn only.
Low main.rs:22 race-condition rotate_log_if_large() calls metadata() (follows symlinks) then rename() — TOCTOU window. An attacker can replace ziplock.log with a symlink between stat and rename, causing rename to move an attacker-chosen file. Use symlink_metadata() (lstat) instead of metadata() in rotate_log_if_large; refuse to rotate if the path is a symlink.
Low main.rs:31 insecure-local-storage ~/.claude is created with create_dir_all which respects umask (typically 0755), making the directory world-listable. Any local user can confirm ziplock is running and observe log file sizes. Create ~/.claude with DirBuilder::new().mode(0o700) so the directory is inaccessible to other users.
Low sandbox.rs:33 exceptional-conditions Command::output() buffers all stdout from claude --version into memory with no size limit. A malicious or corrupted claude binary streaming gigabytes causes OOM before the version string is parsed. The probe runs before the sandbox is applied, so the sandbox cannot contain it. Replace with a bounded read: spawn the child, read at most 256 bytes from stdout, kill the child, parse. parse_claude_version needs only the first whitespace-delimited token.
Low dns.rs:30 unsafe-api-consumption DoH resolver uses the Mozilla CA bundle (webpki-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. Document the trust assumption in an ADR. For higher assurance, implement SPKI pinning against Cloudflare's DoH leaf certificate hash.
Low dns.rs:14 security-misconfiguration Only two Cloudflare DoH IPs are configured with no fallback provider. If both become unreachable, all DNS resolution fails and Claude loses network access entirely. Add a secondary DoH provider (e.g. Google 8.8.8.8/dns.google) as a fallback attempted after Cloudflare times out.
Low proxy.rs:428 ssrf parse_host_port accepts [evil.com]:443 as valid bracket notation and extracts evil.com as 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. After extracting bracket content, verify with .parse::<Ipv6Addr>().is_err() and reject non-IPv6 values.
Low sandbox.rs:431 ipc-security Bare (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. Evaluate whether per-user lookup is necessary; if so, enumerate required names and add them to the allowlist, then remove the bare rule.
Low sandbox.rs:453 ipc-security Bare (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. Remove the bare rule. If specific socket types are needed, name them explicitly.
Low sandbox.rs:446 sensitive-disclosure Bare (allow iokit*) includes iokit-open, which grants IOKit service connections to kernel drivers. The minimum needed for GPU/Metal queries is iokit-get-properties only; iokit-open has historically been the entry point for kernel escalation CVEs. Replace (allow iokit*) with (allow iokit-get-properties). Add (allow iokit-open (iokit-user-client-class "IOAcceleratorClient")) only if Metal requires it.
Low sandbox.rs:454 ipc-security Bare (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. Restrict to specific notification names used by the runtime, or document the accepted risk.
Low sandbox.rs:537 broken-access-control find_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 Claude file-write* access to that path. Require an exact bundle-ID prefix match (e.g. 2BUA8C4S2C.com.1password). Verify the directory contains an expected sentinel file (agent.sock) before granting access.
Low proxy.rs:98 ssrf is_blocked_ip() checks Ipv4Addr::UNSPECIFIED and Ipv6Addr::UNSPECIFIED separately but not the IPv4-mapped form ::ffff:0.0.0.0. A resolver returning ::ffff:0.0.0.0 for a blocked domain produces a misleading "private IP" log entry instead of "DNS-blocked domain," masking the block reason. Add v6.to_ipv4_mapped() == Some(Ipv4Addr::UNSPECIFIED) to is_blocked_ip() for consistency.
Low sandbox.rs:550 insecure-design find_1password_dirs() probes agent.sock existence via sock.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. Use symlink_metadata() and verify the result is a socket (FileType::is_socket()) before accepting the path.
Low dns.rs:36 exceptional-conditions hickory-resolver's default retry count may issue multiple retries per failing query. With no circuit breaker and no connection cap, many simultaneous npm-install-style connections during a DoH outage stack into large numbers of 5 s × retries waits, exhausting Tokio threads. Set opts.attempts = 1 and add a circuit-breaker watch channel to short-circuit resolution quickly when DoH has been unavailable for N consecutive queries.
Low proxy.rs:170 ssrf resolve_local_mdns() routes .local hostnames 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. Log an audit WARN for every .local connection. Consider restricting .local connections to a specific allowlist of ports/services actually required.
Low proxy.rs:479 ssrf extract_host_from_url() accepts https:// as a valid scheme for plain-HTTP (non-CONNECT) proxy requests, forwarding them in plaintext — silently downgrading HTTPS to HTTP without any TLS. In the plain-HTTP proxy branch, reject any 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; because resolve_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 the 64:ff9b::/96 prefix (F05), a Cloudflare DoH AAAA response of 64:ff9b::169.254.169.254 passes 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 (bare mach-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 Containers whose name contains 1password as a substring receives a file-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/path before ziplock starts causes the SBPL profile to grant Claude file-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 claude binary is replaced between the version probe and the sandboxed exec, 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 HOME value 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 controls HOME and 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() and rename() in rotate_log_if_large allows an attacker to swap ziplock.log for 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 malformed attacker.com@legitimate.com host string that logs ambiguously and confuses DNS resolution. F06 then forwards the original raw request bytes verbatim to the upstream server, including a Host header 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 forwarded Host header 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-cleanup to interactively apply fixes.


Generated by Soundcheck

Automated rewrites from the Soundcheck security review action.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants