The Alias Was Ours. The Target Wasn't: CVE-2025-13437 in google/zx

How setup created a temporary node_modules link, teardown remembered its resolved target, and an ownership mistake turned cleanup into recursive deletion outside the working tree

At the end of a successful zx run, the temporary link was gone.

So was the directory it pointed to.

That was the part that made me stop.

zx had created:

cwd/node_modules

but cleanup deleted:

external/node_modules

The executed script did not remove anything. The external dependency tree disappeared because setup created one filesystem object and teardown remembered another.

That is CVE-2025-13437.

The bug affects zx versions before 8.8.5 and is classified as CWE-706: Use of Incorrectly-Resolved Name or Reference. The public description is accurate: with --prefer-local=<path>, zx could create a temporary ./node_modules link and later recursively delete the external <path>/node_modules target.

But the interesting part is smaller than the advisory wording.

The helper created an alias.

It returned the target.

Cleanup trusted the return value.

setup created the alias
cleanup remembered the target

Everything else follows from that.

The lifecycle, and where it breaks

Setup creates one object and borrows another. The bug is which of the two the helper hands back for cleanup to destroy.

Before 8.8.5

cleanup reaches the borrowed tree

  1. external/node_modules already exists. This run did not create it.
  2. zx is invoked with --prefer-local pointing at that external path.
  3. Setup creates cwd/node_modules as a link to the external tree.
  4. The helper returns the target, external/node_modules, not the link it made.
  5. The caller stores that returned path in nmLink.
  6. The script runs and finishes.
  7. Teardown calls rmrf(nmLink), which recursively deletes external/node_modules.

After the repair

cleanup stays on the created link

  1. Setup creates cwd/node_modules exactly as before.
  2. The helper returns the alias, cwd/node_modules, which is what it created.
  3. nmLink holds the link, so cleanup is aimed at an object this run owns.
  4. Cleanup inspects the entry and unlinks it rather than recursing into it.
  5. external/node_modules survives.

The two paths looked equivalent until cleanup

The vulnerable path was part of --prefer-local=<path>.

For the feature, zx needed to make an external dependency tree visible from the script working directory. Conceptually, setup produced this relationship:

cwd/node_modules -> external/node_modules

Both paths are valid. Both normally end in node_modules. Both participate in the same feature.

They are still different resources.

Role Example Ownership
Alias cwd/node_modules Created for this zx invocation
Target external/node_modules Pre-existing state borrowed by this invocation
Cleanup authority Alias Remove only the temporary entry

The distinction is not about whether the process has filesystem permission to delete both paths. A developer tool frequently runs with enough user privileges to modify either one.

The distinction is about lifecycle ownership.

zx created the alias.

It did not create the external dependency tree.

That becomes important as soon as a destructive operation appears later in the lifecycle.

The return value was the bug

Reduced to the relevant dataflow, the vulnerable code behaved like this:

const rmrf = (p: string) =>
  p && fs.rmSync(p, { force: true, recursive: true })

function linkNodeModules(cwd: string, external: string): string {
  const nm = 'node_modules'
  const alias = path.resolve(cwd, nm)
  const target =
    path.basename(external) === nm
      ? path.resolve(external)
      : path.resolve(external, nm)

  if (fs.existsSync(alias) || !fs.existsSync(target)) return ''

  fs.symlinkSync(target, alias, 'junction')
  return target
}

The caller stored the result:

if (typeof argv.preferLocal === 'string') {
  nmLink = linkNodeModules(cwd, argv.preferLocal)
}

and teardown later consumed it:

rmrf(nmLink)

I usually reduce this kind of path handling to identities before thinking about exploitation:

alias  = cwd/node_modules
target = external/node_modules

create(alias -> target)
return(target)
delete(return_value)

After that reduction, the problem is difficult to unsee.

The function performed an operation on alias, but exported target as the lifecycle handle.

The caller variable was named nmLink, which made the mistake easier to read past. The name suggested that teardown held the temporary link. The runtime value said otherwise.

That is a useful audit lesson by itself: lifecycle names are not evidence of lifecycle ownership.

When I see:

remove(temp)
cleanup(link)
delete(generatedPath)

I now care less about the noun and more about the assignment that produced it.

I made the payload boring

Once the dataflow looked wrong, I wanted the reproduction to remove the script body from the argument.

A teardown bug should reproduce even when the payload does almost nothing.

rm -rf /tmp/zx-victim /tmp/zx-work

mkdir -p /tmp/zx-victim/node_modules
mkdir -p /tmp/zx-work

printf 'KEEP_ME\n' > /tmp/zx-victim/node_modules/proof.txt

cd /tmp/zx-work

npx zx@8.8.3 \
  --prefer-local=/tmp/zx-victim \
  -e "console.log('run')"

test -e /tmp/zx-victim/node_modules \
  && echo PRESENT \
  || echo DELETED

The significant output was:

DELETED

The marker existed before zx started.

The script only printed run.

The external node_modules tree disappeared when the CLI cleaned up its own setup.

That separates the vulnerability from the obvious fact that a zx script can execute destructive commands.

The boundary under test was:

borrow external state
        |
        v
create temporary alias
        |
        v
run arbitrary script
        |
        v
cleanup only temporary state

The vulnerable implementation violated the last step.

What I looked at first - and why it was wrong

My first instinct was path traversal. It usually is when I see --prefer-local=<path> feeding into path.resolve.

path.resolve(external, nm) takes the user-supplied string and constructs the target. The obvious question is whether that string can carry .. segments somewhere useful. I started mapping it out.

Then I stopped and asked who controlled the argument.

The developer running zx controls --prefer-local. The script being executed doesn’t touch it. I’d reflexively treated “string from the command line” as hostile input without asking which command line and whose intent was behind it. Someone running zx --prefer-local=../../etc is choosing to aim the tool at their own system. That’s not a vulnerability surface worth pursuing here.

So I moved to the existence checks.

if (fs.existsSync(alias) || !fs.existsSync(target)) return ''
fs.symlinkSync(target, alias, 'junction')
return target

There’s a real window between checking alias and creating the link. Classic TOCTOU setup - someone places a real directory at the alias path in that window, and the behavior at creation changes. I spent a while here.

The problem is that the bug I was looking for happens at teardown, not at creation. Even if you win the race and force symlinkSync into unexpected behavior, the dangerous event is rmSync running four seconds later. A race at setup doesn’t rewrite what’s stored in nmLink when cleanup arrives. I was looking for a setup-time exploit to explain a teardown-time effect.

One more: can a second concurrent zx invocation’s cleanup reach the first one’s alias? nmLink is a local variable, scoped to one invocation. No shared state. Nothing to interfere with.

After those three dead ends, I stopped looking for where the wrong path came from.

I looked at what the return value was.

return target.

That’s it. It had been there the whole time, one line past where I’d been reading.

The filesystem relationship uses a link, so CWE-59 is an understandable first association.

But the link itself is not sufficient to explain the failure.

The decisive mistake happened when resource identity crossed the helper boundary.

created resource:  cwd/node_modules
borrowed resource: external/node_modules
retained handle:   external/node_modules
destructive sink:  recursive remove(retained handle)

The program retained the name of a resource outside the intended cleanup control sphere and later used it as if it represented temporary state.

That is why CWE-706 is a strong fit.

The useful invariant is not:

never use symlinks

It is:

cleanup may destroy only resources this lifecycle created
or explicitly adopted as its own

That applies beyond symbolic links: junctions, mount points, bind mounts, generated paths, workspace aliases, cache indirections, staging directories, and other places where one resource temporarily represents another.

The trigger conditions matter

I do not like jumping from suspicious code directly to a maximal impact statement.

For this path to delete the external dependency tree, several conditions had to hold.

The --prefer-local argument had to be a string path.

The working directory could not already contain a blocking node_modules entry in the vulnerable setup path.

The external target had to resolve to an existing dependency tree.

The helper had to create the alias successfully.

The returned value then had to survive until teardown.

Finally, cleanup had to apply recursive deletion to that retained target.

Written as a state transition:

external target exists
        +
local alias absent
        +
link creation succeeds
        +
helper returns target
        +
teardown calls recursive remove
        =
borrowed dependency tree deleted

These conditions are useful for two reasons.

First, they keep the write-up honest about reachability.

Second, they expose where a patch can break the chain and where a patch can restore the underlying model.

Those are not always the same thing.

Why normal feature testing can miss it

A normal test for --prefer-local naturally asks:

Can the script load the requested local dependency?

The vulnerable implementation can pass that test.

The alias is created correctly.

The external dependency is visible.

The script runs.

Only teardown is wrong.

A lifecycle feature therefore needs two classes of assertions:

positive:
the borrowed resource became usable

preservation:
the borrowed resource still exists after cleanup

The second assertion is the one that catches this bug.

This is a pattern I now look for in temporary-resource code. If a feature borrows pre-existing state, a regression test should verify both that the state was usable and that it survived the lifecycle.

The first patch stopped the deletion

The first fix was discussed in google/zx#1349.

My initial repair had two pieces:

  1. return the alias from linkNodeModules
  2. make cleanup link-aware

The second part narrowed the destructive sink:

const rmrf = (p: string) => {
  if (!p) return

  try {
    fs.lstatSync(p).isSymbolicLink()
      ? fs.unlinkSync(p)
      : fs.rmSync(p, { force: true, recursive: true })
  } catch {}
}

For an alias, unlink expresses the operation cleanup actually needs.

There is no reason to recursively remove a target tree when the temporary object is a directory entry.

The maintainer discussion also raised a compatibility concern around changing the helper return value. That mattered because a security patch is still a software change: even a wrong behavior may already have callers or assumptions around it.

The first merged fix therefore blocked the dangerous cleanup path while preserving more of the existing helper behavior.

At the call site, cleanup was redirected toward the alias instead of trusting the target returned by the helper.

From an exploit perspective, that is the important boundary:

before:
cleanup -> external/node_modules

after:
cleanup -> cwd/node_modules

The external tree no longer had to be the cleanup victim.

The follow-up fixed the contract

Stopping the deletion was necessary, but the intermediate state still left an awkward API.

The helper was responsible for creating a node_modules link.

The caller variable represented the link that might need cleanup.

Yet the helper still returned the target.

That meant the caller had to know enough about the helper internals to reconstruct the actual owned object.

The follow-up in google/zx#1355, merged as commit a4d1bc2, completed the model repair.

The call site could again keep the helper result directly:

if (typeof argv.preferLocal === 'string') {
  nmLink = linkNodeModules(cwd, argv.preferLocal)
}

and the helper returned what it created:

fs.symlinkSync(target, alias, 'junction')
return alias

That change is only one line in the reduced model:

- return target
+ return alias

but it repairs the lifecycle contract.

The first patch made exploitation fail.

The follow-up made the API tell the truth again.

Three states, not two

Blocking the exploit and repairing the contract were separate changes, in that order. The middle state is secure and still returns the wrong path.

  1. Vulnerable

    The helper returns what it borrowed

    Setup creates the alias and hands back the target, so the caller stores an identity this run does not own.

    reduced model, as published above
    
                
                Unchanged line. 
                fs.symlinkSync(target, alias, 'junction')
              
                
                Removed line. 
                return target
              
  2. Exploit blocked

    Cleanup stops recursing into a link

    The destructive sink is narrowed: an entry that is a symbolic link is unlinked rather than removed recursively.

    
                
                Unchanged line. 
                const rmrf = (p: string) => {
              
                
                Unchanged line. 
                  if (!p) return
              
                
                Added line. 
                  fs.lstatSync(p).isSymbolicLink()
              
                
                Added line. 
                    ? fs.unlinkSync(p)
              
                
                Added line. 
                    : fs.rmSync(p, { force: true, recursive: true })
              
                
                Unchanged line. 
                }
              
  3. Contract repaired

    The helper returns what it created

    One line, and the signature stops lying: the function hands back the object it made, so a caller can trust the result again.

    
                
                Unchanged line. 
                fs.symlinkSync(target, alias, 'junction')
              
                
                Removed line. 
                return target
              
                
                Added line. 
                return alias
              

That distinction is useful during patch review. A patch can be secure at the immediate exploit boundary while still preserving the abstraction that produced the vulnerability.

For variant resistance and future maintainability, I want both.

What the function signature was silently claiming

Something I keep thinking about after this bug is how the function signature made the mistake almost frictionless to write.

linkNodeModules(cwd: string, external: string): string

Two paths in - one the invocation owns, one it’s borrowing - and one untyped string out. TypeScript can’t say which of the two roles that output carries. The type just says “string.” And the variable on the call site said nmLink, which felt completely right. It’s a node_modules link. That’s the name you’d give it.

The variable name was doing the security reasoning. The runtime value quietly disagreed.

If you tried to make that disagreement visible in the type system - not a realistic production requirement, but an honest audit exercise:

type OwnedEntry   = string & { readonly _owned: true }
type BorrowedPath = string & { readonly _owned: false }

function linkNodeModules(cwd: string, external: string): OwnedEntry | null

Now return target is a type error. The function that creates an alias using an external resource can’t satisfy a return type that claims ownership - because target is a BorrowedPath. The compiler would catch it before anyone named the variable.

But most codebases can’t say this, and most won’t. So the audit has to ask the question manually:

Which path did this function create? Which path did it return?

If those answers are different, every caller doing destructive work with the result has an ownership problem it may not know about.

What the two-stage patch history makes clear is that fixing the exploit and repairing the contract are genuinely separate things. After the first patch, cleanup was redirected - the caller was changed to not use the return value blindly. The function still returned the wrong path. It just wasn’t consumed anymore. The second patch fixed the function itself to return alias.

After that, a caller can trust the return value directly, because now the function tells the truth about what it created.

Both matter. A security patch can be exploit-correct while the API keeps lying. The lying matters eventually - to the next developer who reads the signature, trusts it, and writes a new caller.

lstat asks the right question

The final repair also benefits from treating alias management as an entry-oriented problem.

existsSync() is often used as a convenience check, but links create an important distinction between two questions:

Can this path be followed to a live target?

Does a directory entry exist here, and what is it?

For lifecycle ownership, the second question is usually the relevant one.

A dangling symlink is still an object in the parent directory even when its target does not exist.

lstat inspects the entry itself.

That matters when deciding whether setup is allowed to create something at the path, whether an existing entry is owned by the current invocation, and whether cleanup should unlink an alias rather than recurse into an object.

The same reasoning also catches a second edge case: alias and target can collapse to the same effective resource.

If setup is asked to create an alias that is effectively the object it is supposed to point at, it must not create a self-reference or later claim pre-existing state as temporary state.

Resource identity cannot always be inferred from string construction alone.

Pre-existing state must not become temporary state

There is another lifecycle trap around an already-existing cwd/node_modules.

Suppose it is a real directory.

Or a valid symlink.

Or a dangling symlink.

Even if it happens to point at the same target the user requested, the current invocation did not necessarily create it.

That means cleanup cannot safely reason:

this is the alias path
therefore I own it

The stronger condition is:

this invocation created this entry
therefore this invocation may remove this entry

This is why I prefer thinking in terms of ownership rather than only path equality.

A path can be correct and still be unowned.

The regression property is preservation

The security property I want a test to encode is small:

After zx exits, external dependency state still exists.

A useful regression setup is:

external/node_modules/a/index.js

Run zx with --prefer-local pointing at the external project.

Verify that the dependency is usable during execution.

Then verify after exit:

cwd/node_modules

is gone if this invocation created it, while:

external/node_modules/a/index.js

still exists.

I would also cover failure paths because cleanup bugs often hide there.

A compact lifecycle matrix would include:

Case Expected cleanup behavior
Fresh alias created by this run Remove alias only
Pre-existing real node_modules Preserve it
Pre-existing symlink Preserve unless explicitly owned
Dangling symlink Detect the entry deliberately; do not silently adopt it
External target missing Do not create alias
External target not a directory Reject setup
Script throws Remove only invocation-owned alias
Cleanup invoked twice Second cleanup is harmless
Alias and target collapse Do not create self-reference or claim ownership

The exact implementation can change.

The preservation property should survive the refactor.

The audit pattern is larger than zx

After reducing the bug, the pattern becomes reusable:

setup creates X using Y
helper returns Y
caller stores one untyped handle
teardown destroys the stored handle

In this case:

X = cwd/node_modules
Y = external/node_modules

The dangerous part is not the existence of two paths.

It is compressing two resource roles into one value without preserving which one is owned.

I would hunt this pattern from destructive sinks backward.

High-signal sinks include:

fs.rm(... recursive ...)
fs.rmSync(... recursive ...)
rimraf(...)
rm -rf
shutil.rmtree(...)
os.RemoveAll(...)
remove_dir_all(...)
cleanup(...)
finally { remove(...) }

For each sink, I would trace the argument back through setup and ask:

1. What exact object did this invocation create?
2. What exact object did it borrow?
3. Which identity survived setup?
4. Why is teardown authorized to destroy that identity?

That is usually more productive than starting with every path-normalization branch in the feature.

The destructive sink defines the authority.

The dataflow tells you whether that authority escaped its intended object.

Patch review should follow the violated invariant

The original issue was easy to state:

linkNodeModules returns target
cleanup deletes returned value

Patch review needs one level more precision.

There are at least three questions:

Does the published PoC stop working?

Can cleanup still reach borrowed state through another path?

Does the repaired API preserve the correct resource identity?

The first merged fix answered the immediate security question.

The follow-up improved the third.

That history is useful because it shows why I do not treat “fixed” as a single binary property when reviewing security changes.

A patch may:

break the trigger
narrow the sink
restore the invariant
remove variant surface

The strongest fixes usually do more than one.

For this bug, the durable state is simple:

producer returns the alias
cleanup inspects the alias as an entry
cleanup unlinks links
borrowed target survives

Now the code and the ownership model agree.

Why CWE-706 is the useful classification

The public advisory classifies CVE-2025-13437 as CWE-706.

That classification captures the failure better than treating the issue as generic dangerous deletion.

The lifecycle held a name/reference that resolved outside the intended cleanup control sphere.

The process had permission to operate on the external dependency tree.

The lifecycle did not own it.

That difference matters in developer tooling, package managers, build systems, CI helpers, and deployment utilities because those programs often execute with broad filesystem permissions.

Security boundaries in these tools are not always Unix privilege boundaries.

Sometimes the important boundary is:

what this process can modify

versus:

what this operation is responsible for modifying

zx crossed the second one.

Why this lands differently in developer tooling

In most server contexts, a lifecycle ownership bug has a natural ceiling.

The service user is narrow. The process can’t write to paths it doesn’t own. If cleanup authority escapes to an external resource, there’s a decent chance the OS says no - process doesn’t have permissions, deletion fails, damage is contained.

Developer tools don’t have that ceiling.

They run as you. Your home directory, your workspace, your local dependency caches, your linked packages, your sibling project trees - all of it writable by the process running zx. When zx decided to call rmSync on an external node_modules tree, it had every permission it needed. No privilege boundary in the way. The OS did exactly what it was asked.

The --prefer-local feature makes this concrete. The whole point is that a developer can borrow a sibling project’s dependency tree - a local library they’re developing alongside the current script. That sibling tree has its own history, its own contents, its own life outside this zx invocation. zx borrows it for one run.

The borrowing creates a relationship the filesystem can’t model:

this invocation may read from: external/node_modules
this invocation must not touch: external/node_modules

The user owns the directory. The OS enforces permissions, not intent. The second line only exists inside the lifecycle design.

When the lifecycle design breaks, the user sees something that feels like a security boundary violation - “it deleted files I never told it to touch.” But nothing escalated. No privilege was involved. Every syscall was authorized. The boundary that failed was entirely the tool’s own lifecycle contract.

That’s the thing about developer tooling specifically: package managers borrow global caches, build systems borrow upstream compilation artifacts, test runners borrow shared fixture directories, workspace tools borrow symlinked packages. All of them run with the developer’s credentials. All of them have cleanup steps. And almost none of them are tested for whether cleanup authority stays inside what the invocation actually created.

The OS isn’t tracking that. The lifecycle code is. When the lifecycle code is wrong, there’s nothing left to catch it.

What I would search for next

I would not search other repositories for the literal string:

return target

I would search for lifecycle asymmetry.

Examples:

create alias -> return canonical target -> cleanup returned path
mount source at temporary destination -> remember source -> unmount/remove source
extract into staging entry -> retain resolved external path -> rollback retained path
create temporary redirect -> canonicalize it -> delete canonical result
borrow cache/workspace -> label it temporary -> recursive cleanup

The variant question is always the same:

Did setup create one resource while teardown acquired authority over another?

That is the reusable bug class I take from this case.

What the pattern looks like across languages

The abstract shape is: create alias, return target, delete return value. The concrete code shapes that carry it are recognizable once you’ve seen the original.

In TypeScript and JavaScript:

// the original shape
fs.symlinkSync(target, alias, 'junction')
return target

// the canonicalization variant - looks careful, isn't
const resolved = fs.realpathSync(alias)   // follows the link, resolved === target
return resolved                            // now cleanup will reach target

The realpathSync variant is the one I’d be most likely to miss on a quick read. It looks like defensive coding - you’re resolving the canonical path, removing ambiguity. But resolving a symlink through the link erases the alias identity. realpathSync(alias) returns target. Whatever cleanup consumes that stored value is now aimed at the external resource, not the temporary entry.

Python:

os.symlink(target, alias)
return target                    # wrong end

# staging pattern, cleanup on wrong object
os.makedirs(staging)
shutil.copytree(external, staging)
try:
    work(staging)
finally:
    shutil.rmtree(external)      # should be staging

# same realpath trap
os.symlink(target, alias)
stored = os.path.realpath(alias)  # follows the link - stored == target
cleanup(stored)

Go:

os.Symlink(target, link)
return target, nil               // wrong end

defer os.RemoveAll(target)       // should be: defer os.RemoveAll(link)

Rust, where the Drop implementation makes it worse:

std::os::unix::fs::symlink(&target, &alias)?;
return Ok(target.to_path_buf());  // should return alias

impl Drop for TempLink {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.target); // should be self.alias
    }
}

The Drop case is particularly quiet because the ownership claim is implicit in the struct definition. A TempLink holding both alias and target fields, with a drop that removes target, is broken at the design level - but nothing in the struct signature marks which field cleanup is authorized to own. You have to read the Drop implementation to find it.

The search I’d run: start at destructive sinks - rmSync, rmtree, RemoveAll, remove_dir_all, finally { remove(...) } - and trace each argument backward through assignments and function returns. At every function that accepted an external path as input, ask whether it handed back that external path or something it created itself. The sink defines what the program is claiming authority over. The trace tells you whether that authority was correctly scoped.

Add realpath, canonicalize, filepath.EvalSymlinks, and fs.realpathSync to the list of things to pause on in that trace. Resolving through a link before storing is how the alias identity disappears before cleanup runs - same class, one extra step in the middle.

Final invariant

The vulnerability can be described in one sentence:

Create the alias, borrow the target, and let cleanup destroy only the alias.

For CVE-2025-13437, the bug was not that zx could resolve an external path.

That was the feature.

The bug was that the resolved target crossed a lifecycle boundary and became the cleanup handle.

Once that happened, recursive deletion was operating on a valid path with the wrong ownership attached to it.

The PoC showed the deletion.

The dataflow explained why it happened.

The two-stage patch history showed the difference between blocking the exploit and repairing the contract.

The regression test turned the fix into a preservation property:

borrowed state must survive cleanup

That is the part I would carry into the next audit.