Skip to content

CWE-402: Transmission of Private Resources into a New Sphere ('Resource Leak')

Overview

Transmission of a private resource into a new sphere happens when a resource handle a program holds - an open file descriptor, a listening or connected socket, a database cursor - survives into a context that was never meant to hold it. The usual route is a child process inheriting whatever the parent had open across exec, but the same shape appears when a handle outlives a privilege drop or is left open for the next user of a shared connection. Nothing is copied and no data is deliberately sent: what crosses the boundary is the capability to use the resource, still carrying the access rights it was opened with.

Relationship to Other CWEs

A private file served to a client over HTTP is better mapped elsewhere, even though the words look similar. CWE-402 is about the handle, not the bytes. Route those findings to:

  • CWE-22 - a caller-supplied path escaping the intended directory
  • CWE-548 - a directory listing revealing what exists
  • CWE-538 - a backup, VCS or config file reachable in the webroot
  • CWE-200 - a response carrying fields the caller should not see

OWASP Classification

A01:2025 - Broken Access Control

Risk

High: an inherited descriptor is a live grant of whatever access it was opened with, and it survives the checks that guarded the original open. A child process spawned to run untrusted or lower-privilege work can read a private key file the parent opened as root, or write to a log or database the sandbox was supposed to deny it. Where the leaked handle is a listening socket or a control channel, the child can take over the service's endpoint. A dangling database cursor gives the next holder of the session the privileges the cursor was created with, which is the usual route from cursor leak to SQL injection.

Remediation Steps

Core Principle: A handle must not survive a boundary crossing it was not explicitly granted - open it close-on-exec, and pass a child or a successor only the specific handles it needs.

Trace the Handle

  • Source: Where the handle is acquired, and with what rights - a file opened before a privilege drop, a socket bound to a privileged port, a cursor opened under a definer's rights, a connection taken from a pool.
  • Sink: The boundary the handle crosses - exec of a child process, a fork followed by running someone else's code, a privilege drop, returning a connection to a pool, or the end of a request that leaves the cursor open.
  • Missing control: The handle was not marked close-on-exec (or the runtime's inherit-everything default was left in place), the spawn passed the whole descriptor table rather than a chosen set, or an error path skipped the close.

Make Handles Close-on-Exec at Open Time (Primary Defense)

Request close-on-exec as part of the call that creates the handle, not as a second step:

  • Pass O_CLOEXEC to open (and the equivalent flag on socket, pipe and accept calls). The open(2) man page gives the reason the flag exists: setting close-on-exec afterwards with fcntl races a fork/execve in another thread, and the descriptor can be inherited before the second call lands.
  • Where an API cannot take the flag, set FD_CLOEXEC with fcntl(fd, F_SETFD, ...) immediately after the open, and treat the window as a known residual risk in multithreaded code.
  • posix_spawn file actions let the caller close or remap specific descriptors as part of the spawn, but anything not named is still inherited under ordinary exec semantics. posix_spawn(3) is explicit that with no file actions "file descriptors open before the exec remain open in the new process, except those for which the FD_CLOEXEC flag has been set". Open-time O_CLOEXEC remains the control; enumerating closes at spawn time is not an alternative to it.

Managed runtimes mostly default to non-inheritance already, and the finding is usually an explicit opt-in that was added for convenience:

  • Python has created descriptors non-inheritable by default since 3.4 (PEP 446). Descriptors from os.open, open, and socket get close-on-exec on Unix and a cleared HANDLE_FLAG_INHERIT on Windows, and os.set_inheritable() is the deliberate opt-back-in. The default covers descriptors Python itself creates; one opened inside a third-party C extension is not affected. subprocess also closes non-standard descriptors in the child unless pass_fds names them.
  • Java ProcessBuilder gives the child only the streams it redirects; by default those are pipes back to the parent. inheritIO() is the opt-in that hands the child the JVM's own standard streams.
  • Node.js child_process spawns with piped stdio by default. stdio: 'inherit' passes the parent's streams through, and a positive integer in the stdio array shares that parent descriptor with the child - both are explicit grants, so check that each one is needed.
  • Go exec.Cmd passes nothing beyond the configured standard streams unless ExtraFiles is set; entries there become descriptors 3, 4, ... in the child. An ExtraFiles entry is the grant to justify. The field is documented as unsupported on Windows.

Pass Only the Handles the Far Side Needs

Where a child needs a resource, hand it one specific descriptor rather than the parent's table, and hand it the narrowest one available: a read-only descriptor rather than read-write, a descriptor for the single file rather than one for the directory. If the child only needs data, prefer copying the data over a pipe to passing the handle at all. A pipe carries bytes the parent chooses and the child sees EOF once every write end is closed, whereas a passed descriptor carries the access rights it was opened with and lets the child read whatever the resource holds.

Close Handles Before Crossing a Trust Boundary

A privilege drop does not revoke handles already open. Anything opened while privileged - a key file, a bound port, an audit log - keeps its original access after the process drops to the service account, so close what the post-drop code does not need before dropping, and re-open the rest as the lower identity. The same applies to a connection or worker handed on to another tenant's request.

Close Cursors, Statements and Connections on Every Path

Close database cursors, prepared statements and result sets in the construct that runs on the error path too, not only on success - an unhandled exception is the usual way a cursor is left dangling. Return connections to the pool in a reset state so no cursor, temporary table or session variable carries into the next borrower.

Test the Fix

  • Spawn the child the finding names and enumerate the descriptors it actually holds (/proc/<pid>/fd on Linux, lsof -p), and confirm only the intended ones are present.
  • Make the child attempt the access the leaked handle would have granted - reading the key file, writing the privileged log - and confirm it fails.
  • Force an exception in the middle of a cursor's lifetime and confirm the cursor is closed anyway.
  • Re-scan with the security scanner to confirm the finding is resolved.

Common Vulnerable Patterns

The usual shape is a handle opened under one set of rights and still open when the process crosses into a context with fewer: the spawn never mentions the descriptor, so nothing in the code reads as a grant of access.

// VULNERABLE - pseudo-code
key_fd = open('/etc/service/private.key')      // opened while privileged
drop_privileges_to('service')
spawn('/usr/bin/render', untrusted_input)      // child inherits key_fd
// Attack: the child process reads the private key through the inherited
// descriptor, having never been granted access to the path
// Result: a key the OS would have denied the child is readable by it

Secure Patterns

// SECURE - pseudo-code
key_fd = open('/etc/service/private.key', CLOSE_ON_EXEC)
drop_privileges_to('service')
spawn('/usr/bin/render', untrusted_input,
      handles = [stdin_pipe, stdout_pipe, stderr_pipe])   // nothing else

Why this works: the close-on-exec flag is set atomically by the call that creates the descriptor, so there is no window in which another thread's exec can inherit it, and the kernel closes it during exec without the parent having to remember to. Naming the child's handles explicitly makes inheritance a grant rather than a default, so a descriptor opened later somewhere else in the process does not silently join the set the child receives.

Common Pitfalls

  • Setting close-on-exec after the open: correct in a single-threaded program, racy in a threaded one. Another thread can fork and exec between the open and the fcntl, and the child gets the descriptor. Use the open-time flag wherever the API offers one.
  • Fixing the descriptor the finding names and stopping there: the reported line is a sample. Every handle open at the moment of the spawn is inherited under the same default, so audit what else is open on that path rather than flagging one descriptor.
  • Treating a privilege drop as revocation: dropping to an unprivileged account stops the process opening new privileged resources; it does nothing to the ones already open. The handles have to be closed separately.
  • Closing the language-level object and assuming the descriptor is gone: a stream, reader or connection wrapper that has been garbage-collected but not closed may still hold the underlying descriptor until finalization, which can be after the spawn. Close it explicitly.

Additional Resources