Skip to content

Security: tap-controlled path traversal and post_install sandbox escapes #789

Description

@rustytrees

Summary

While reading through malt I found six issues that let a third-party tap reach outside the prefix on a single mt install. Each was reproduced by executing malt's own functions against Zig 0.16.0 — they are not static-analysis guesses. Fixes and regression tests are up as six PRs against this issue — see What I'm proposing at the bottom for the mapping.

Opening this publicly because the repo has no SECURITY.md and private vulnerability reporting is disabled, so there is no private channel. If you'd rather coordinate privately, enable Settings → Security → Private vulnerability reporting and I'll move the detail there.

Threat model

malt downloads and unpacks third-party code as the invoking user into a user-owned prefix that mt shellenv puts on PATH. Taps on GitHub/GitLab/Gitea/Gogs control cask JSON, formula post_install, and the archive bytes. The bar I'm applying: mt install <tap>/<pkg> should not be able to touch anything outside the prefix.

Findings

1. Writing through an archive's own symlink (src/fs/archive.zig)

isSafeSymlinkTarget allows a target to climb one level above the extraction root — correctly, because bottles are built for their installed location and reach sibling formulas that way (rust's ../../../../../../../opt/llvm/bin/llvm-objcopy). The allowance is sound for a leaf link, but the depth arithmetic counts every entry-name component as a real directory, so once a exists as a symlink it stops describing the filesystem and "one level up" composes:

a           -> ".."     (accepted, at the budget)
a/b         -> ".."     (accepted; `a` is already a symlink)
a/b/bin/EVIL            (lands in a sibling of the extraction root)

In the real layout that reaches <prefix>/bin. extractTarGz returned success. std.tar uses O_EXCL so nothing is overwritten, but creating a new command name on PATH is enough.

2. Hard links resolved through a planted symlink (applyHardLinks)

follow_symlinks = false only governs the final component; the kernel still resolves intermediate directories. A hard link is a second name for the inode, so a target routed through a planted symlink imports a file the archive never shipped into the keg — where the linker can expose it and a later chmod can widen it. Reproduced; returned success.

3. Cask app artifact reaches deleteTree() unvalidated (src/core/cask.zig)

parseCask screens token/version through fs/path_component.zig and calls itself "the one ingestion choke point". Artifact strings go straight through. app_name is interpolated into <app_dir>/<name> and passed to deleteTree before and independently of the copy, so {"app":["../PRECIOUS"]} recursively deleted a directory outside the Applications dir even though the install then failed.

4. Cask binarytarget escapes <prefix>/bin

parseBinaryTarget returns the raw JSON string, which becomes <prefix>/bin/<target> for a deleteFile + symLinkAbsolute pair. ../../../../Users/x/.zshenv deletes that file and points it at a binary the cask fully controls. The basename(src_name) fallback is already safe; only the explicit target is unguarded.

5. Utils.safe_popen_read bypasses the post_install sandbox (src/core/dsl/builtins/process.zig)

system is gated by validateArgv and wrapped in sandbox-exec by fenceArgv. safe_popen_read — same DSL surface, reachable from any formula — does neither. Side by side with the same command: systemOperation not permitted; safe_popen_read → wrote outside the keg, unconfined, network allowed.

6. ln_s + rm_rf deletes arbitrary trees (src/core/dsl/builtins/fileutils.zig)

ln_s never validated its target. rm_r/mkdir_p validated only lexically while cp/cp_r already resolve intermediate symlinks via validateWriteDir. A planted directory symlink turned a keg-local path into an out-of-keg recursive delete. These run in malt's own process, so the sandbox-exec fence does not apply.

Also worth a look

  • A cask with no sha256 verifies as if it opted out. verifyFileSha256 treats null like no_check. Homebrew always emits the field, and a pkg artifact goes to sudo installer -target /.
  • resolveCaskBinaryPath joins a /-bearing src to the root with no traversal check, then opens read-write and chmod 0755.
  • No URL scheme allowlist. The redirect logic is careful (credentials dropped cross-domain, https→http refused mid-chain), but an http:// origin is followed as-is.
  • term_sanitize.zig is only wired into the --use-system-ruby path, not the native interpreter, which is the default. Its doc comment claims it wraps post_install output generally.
  • Release workflow pins actions by mutable tag. Job permissions are properly minimal, but a compromised mlugg/setup-zig@v2 runs inside the job whose output cosign then signs — which cuts against the README's "a leaked GitHub token is not enough to ship a malicious binary".
  • cosign is resolved via PATH, which includes the user-writable prefix that packages write into.
  • The test suite is not hermetic. Three tests make live requests to formulae.brew.sh/httpbin.org, and one built its client from the process environ while exercising credential injection — githubApiToken prefers MALT_GITHUB_TOKEN over the var that test sets, so on a machine with malt's own token exported zig build test sent a live credential to a third party. Separately, atomic.maltPrefixOrAbort() reads the ambient environment and defaults to /opt/malt, so a test that forgets a fixture prefix operates on the developer's real install.

What I'm proposing

Six PRs, split so each can be judged — and rejected — on its own. Roughly in
descending order of how much I think they matter:

PR Covers Size Notes
#790 findings 3–6, cask hash policy, resolveCaskBinaryPath, test-suite hygiene +542/−30 one deliberate behaviour change: absent cask sha256 now fails closed
#791 findings 1–2 +210/−2 kept separate; see below
#792 cleartext origins, prefix-resident cosign, install.sh checksum lookup +225/−1
#793 terminal sanitizer missing from the native path +112/−15 piping means post_install children stop seeing a TTY
#794 unpinned CI actions +31/−31 31 refs, only uses: lines touched
#795 vendored SQLite 3.49.1 → 3.53.4 +16k/−7k hygiene, not a CVE fix; entirely reasonable to close

Why #791 is on its own. The obvious fix — forbid symlink targets that
escape the extraction root — breaks every bottle that reaches a sibling
formula via opt/, because bottles are built for their installed location.
Your own tests/archive_test.zig pins the rust case, and it caught my first
attempt. The PR instead enforces "no entry is reached through a symlink this
archive created", which leaves those leaf links untouched. That reasoning is
worth reviewing separately from the mechanical fixes.

Merge-order note. #790 and #792 both touch src/net/client.zig from
independent branches off main, so whichever lands second needs a trivial
conflict resolution. Nothing else overlaps.

One item has no PR. The tar tf / unzip -Z1 listing validation gap is
real but I could not fix it without making things worse — the xz -dc
approach introduces a dependency on a binary macOS does not ship, resolved
from a package-writable directory. Reasoning in
this comment.

Happy to reshape any of these if you'd prefer a different cut — including
folding them back together if six is more review overhead than it's worth.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions