Jan 22, 2026·7 min read·1 visit
CVE-2026-24047 is a path traversal vulnerability in Backstage's `resolveSafeChildPath` function. It arises from improper validation of symbolic links when the target file does not yet exist. Attackers can chain symlinks to escape the intended directory boundary, potentially leading to Remote Code Execution (RCE) by overwriting configuration files or injecting malicious scripts.
A logic flaw in the Backstage framework's path resolution utility allowed attackers to bypass sandbox restrictions using symlinks to non-existent files. By exploiting how the system handled 'phantom' paths, malicious actors could escape the Scaffolder workspace and write files to arbitrary locations on the host filesystem.
Backstage is the darling of Platform Engineering. It's the "portal of portals," designed to unify your infrastructure tooling, services, and documentation into one slick UI. But under the hood, Backstage is doing a lot of heavy lifting—specifically via its Scaffolder. The Scaffolder is essentially a glorified remote file manipulation engine. It clones repos, templating files, moves things around, and pushes code. That sounds a lot like "RCE as a Service" if you aren't careful.
To keep this beast in a cage, Backstage relies on a utility function called resolveSafeChildPath. Its job is simple: ensure that whatever file operations the Scaffolder performs stay strictly within a temporary workspace. It's the bouncer at the club door, checking IDs to make sure no one sneaks into the VIP section (your /etc/shadow file or app-config.yaml).
But here's the thing about filesystems: they aren't just trees; they are graphs. Thanks to symbolic links (symlinks), a path like ./workspace/subdir can instantly teleport you to /root. CVE-2026-24047 is the story of how the bouncer got tricked by a ghost—specifically, how the system failed to validate paths that didn't exist yet.
The vulnerability lives in the gap between intention and reality. When you want to write a file, you usually check if the destination is safe before you write it. Backstage used Node.js's fs.realpathSync to resolve paths to their absolute location, ensuring they started with the safe base directory string.
This works great for files that exist. fs.realpathSync('/safe/base/../../etc/passwd') correctly resolves to /etc/passwd, and the check fails. But what happens if you try to resolve a path for a file you are about to create?
If you ask realpath to resolve /safe/base/symlink_to_root/new_file.txt, and new_file.txt doesn't exist yet, standard implementations often throw an ENOENT error or return the path partially resolved. Backstage's implementation failed to account for dangling symlinks or symlink chains where the final segment is missing.
Because the code couldn't "see" the final file (it wasn't there yet), it essentially shrugged and assumed the path was safe as long as the string manipulation looked okay. This is a classic Time-of-Check to Time-of-Use (TOCTOU) adjacent flaw. The code assumed that if it couldn't prove the path was bad, it must be good. In security, that is a fatal assumption.
Let's look at the logic. The vulnerable code relied too heavily on the happy path of fs.realpathSync. When an ENOENT (File Not Found) error occurred, the validation logic became permissive or incomplete. It failed to walk up the directory tree to verify the parents.
Here is the corrected logic introduced in the patch (Commit ae4dd5d1). Notice the paranoia level has increased significantly. Instead of giving up when a file is missing, it recursively climbs the directory tree until it finds firm ground.
// The Fix: Recursive Resolution
function resolveRealPath(path: string): string {
try {
// 1. Try the standard resolution first
return realpathSync(path);
} catch (ex) {
if (ex.code !== 'ENOENT') {
throw ex;
}
}
// 2. ALERT: The path doesn't exist.
// Check if the path ITSELF is a dangling symlink.
try {
if (lstatSync(path).isSymbolicLink()) {
const target = resolvePath(dirname(path), readlinkSync(path));
// RECURSION: Follow the white rabbit.
return resolveRealPath(target);
}
} catch (ex) { /* ignore */ }
// 3. The file is truly missing.
// We must verify the PARENT directory is safe.
const parent = dirname(path);
if (parent === path) return path; // Hit root
// RECURSION: Resolve the parent, then append the missing child.
return resolvePath(resolveRealPath(parent), basename(path));
}> [!NOTE]
> The Fix Strategy: The key change is that ENOENT is no longer an exit strategy. If the leaf node is missing, the code recursively resolves the parent. If parent turns out to be a symlink to /etc, resolveRealPath(parent) will return /etc, and the final check isChildPath('/safe/base', '/etc/new_file') will correctly return false.
To exploit this, we need to create a situation where we are writing to a file through a symlink, but the file doesn't exist yet so the check passes.
Imagine we have a malicious Scaffolder template. Templates allow us to run shell scripts or file operations. We don't need root; we just need the ability to define a template.
Step 1: The Setup We define a step in our template that creates a symbolic link. This link points to a sensitive directory outside the workspace, like the application's config directory.
# Inside the workspace
ln -s /app/backstage-backend/ malicious_tunnelStep 2: The Bypass
Now, we instruct the Scaffolder to write a file through that tunnel. We ask it to create malicious_tunnel/pwned-config.yaml.
Step 3: The Execution
resolveSafeChildPath('./malicious_tunnel/pwned-config.yaml').realpath looks for pwned-config.yaml. It's not there.ENOENT and fails to fully resolve malicious_tunnel because it was only looking at the full path's existence./app/backstage-backend/pwned-config.yaml.While the CVSS score is a "Medium" 6.3, don't let that fool you. In the right environment, this is a Critical issue. The CVSS metrics often underestimate the impact of file writes in developer tooling.
Configuration Overwrite: The most direct path to total compromise is overwriting app-config.yaml. An attacker could inject a new auth provider (e.g., allowing "guest" access) or change the database connection string to point to a malicious server to steal credentials.
RCE Potential: If the Backstage instance is running scheduled tasks (cron) or using dynamic imports, writing a file to the right place equates to Remote Code Execution. For example, overwriting a script in node_modules or a CI/CD pipeline definition could grant persistent access.
Data Corruption: Even without RCE, an attacker could simply trash the system by overwriting critical system files, causing a Denial of Service. The only thing saving most deployments is that Backstage is typically run as a non-root user (hopefully).
The remediation is straightforward: Update immediately.
@backstage/backend-plugin-api0.1.17If you are using @backstage/cli-common directly in your own custom plugins to validate paths, ensure you have pulled the latest version containing commit ae4dd5d.
Don't just rely on the patch. This vulnerability highlights why application-level sandboxing is fragile.
guest users can run arbitrary templates, you are asking for trouble.CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:N/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
@backstage/backend-plugin-api Backstage | < 0.1.17 | 0.1.17 |
@backstage/cli-common Backstage | < patched version | Commit ae4dd5d |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-59 (Link Following) |
| CVSS v3.1 | 6.3 (Medium) |
| Attack Vector | Network (via Scaffolder Templates) |
| Impact | Arbitrary File Write / Potential RCE |
| Affected Component | resolveSafeChildPath |
| Fix Commit | ae4dd5d1572a4f639e1a466fd982656b50f8e692 |
Improper Link Resolution Before File Access ('Link Following')