Back to Blog
critical SEVERITY9 min read

Path Traversal in ZMODEM Receiver: How a Missing basename() Call Could Overwrite Your SSH Keys

A critical path traversal vulnerability in a ZMODEM file receiver allowed a malicious sender to supply crafted filenames containing directory traversal sequences (like `../../.ssh/authorized_keys`), causing the receiver to write file contents to arbitrary locations on the filesystem. The fix strips path separators and validates filenames before use, ensuring received files can only be written to the intended download directory. This class of vulnerability is a stark reminder that any input origi

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published May 28, 2026•Reviewed June 3, 2026

Answer Summary

Path traversal (CWE-22) in a ZMODEM file receiver allowed attackers to write files to arbitrary filesystem locations by sending crafted filenames containing directory traversal sequences like `../../.ssh/authorized_keys`. The vulnerability occurred because the receiver used sender-supplied filenames directly in file operations without validation. The fix strips all path separators and validates that the resulting filename is safe, using basename extraction to ensure files can only be written to the intended download directory.

Vulnerability at a Glance

cweCWE-22 (Improper Limitation of a Pathname to a Restricted Directory)
fixStrip path separators and validate filenames using basename extraction before file operations
riskRemote attacker can overwrite critical system files like SSH authorized_keys
languageImplementation-agnostic (affects ZMODEM protocol implementations)
root causeAccepting sender-supplied filenames without stripping directory components
vulnerabilityPath Traversal in ZMODEM File Receiver

Introduction

File transfer protocols have been around for decades, and ZMODEM — a protocol born in the 1980s — is still alive and well in terminal emulators, embedded systems, and serial communication tools. But age doesn't mean immunity from security vulnerabilities. A recently patched critical vulnerability in a ZMODEM receiver implementation demonstrates how a single missing validation step can turn a routine file download into a full filesystem compromise.

The vulnerability? A classic path traversal attack (also known as a directory traversal attack), enabled by blindly trusting the filename supplied by the remote sender. No stripping of path separators. No call to basename(). No validation that the resolved output path stays within the intended download directory.

If you write code that accepts filenames from remote sources — whether via file transfer protocols, APIs, or file upload endpoints — this post is for you.


What Is a Path Traversal Vulnerability?

A path traversal vulnerability (CWE-22: Improper Limitation of a Pathname to a Restricted Directory) occurs when an application uses externally supplied input to construct a file path without adequately neutralizing special elements like .. (parent directory), / (Unix path separator), or \ (Windows path separator).

The result: an attacker can "escape" the intended directory and read from or write to arbitrary locations on the filesystem.

In the context of a file receiver, this is especially dangerous. The attacker controls the filename that gets written to disk. If the receiver doesn't sanitize that filename, the attacker controls the destination of the write — and can potentially overwrite any file the receiving process has permission to modify.


The Vulnerability Explained

Where It Lived

The vulnerability existed in lib/qzmodem/qrecvzmodem.cpp, specifically in the rz_process_header() function — the code responsible for processing the file header sent by the remote ZMODEM sender. This header includes, among other things, the filename of the file being transferred.

Here's the vulnerable code path (simplified):

// VULNERABLE CODE (before fix)
name_static = (char *)malloc(strlen(name) + 1);
if (!name_static) {
    qFatal("out of memory");
}
strcpy(name_static, name);  // name comes directly from the remote sender!
zi->fname = name_static;

// ... later ...
pathname = (char *)malloc((PATH_MAX) * 2);
if (!pathname) {
    qFatal("out of memory");
}
strcpy(pathname, name_static);  // written directly as the output path

Notice what's not happening here:
- No stripping of / or \ characters
- No removal of .. traversal sequences
- No call to basename() to extract just the filename component
- No realpath() validation to confirm the resolved path stays within a permitted directory

How Could It Be Exploited?

The attack is straightforward. A malicious ZMODEM sender crafts a file transfer where the filename in the ZMODEM header is not a simple name like document.pdf, but instead a traversal path:

../../.ssh/authorized_keys

or

/etc/cron.d/backdoor

or on Windows:

..\..\AppData\Roaming\malware.exe

The receiver dutifully takes this filename, constructs a path from it, and writes the received file contents to that location. If the receiving process runs with sufficient privileges — or if the target file is writable by the current user — the attacker has just overwritten an arbitrary file with arbitrary content.

Real-World Attack Scenario

Imagine a terminal emulator using this ZMODEM implementation to receive files from a remote server over SSH. A compromised server (or a man-in-the-middle attacker on an unencrypted serial connection) initiates a ZMODEM transfer with the filename ../../.ssh/authorized_keys. The receiver writes the attacker's public key to the user's SSH authorized keys file. The attacker now has persistent SSH access to the victim's machine — without ever needing the user's password.

Alternatively, on a system where the terminal emulator runs as root (common in embedded or industrial systems), the attacker could write to /etc/cron.d/backdoor and establish a cron job for persistent code execution.

CVSS Characteristics:
- Attack Vector: Network (via the file transfer session)
- Attack Complexity: Low (no special conditions required)
- Privileges Required: None (beyond the ability to initiate a ZMODEM transfer)
- Impact: High (arbitrary file write → potential full system compromise)

This is why the severity is rated Critical.


The Fix

The fix addresses the vulnerability at the point where the remote-supplied filename is first processed, before it is ever used to construct a filesystem path.

Before and After

Before (vulnerable):

name_static = (char *)malloc(strlen(name) + 1);
if (!name_static) {
    qFatal("out of memory");
}
strcpy(name_static, name);  // raw remote input, no sanitization
zi->fname = name_static;

After (fixed):

/* strip path separators to prevent directory traversal */
p = strrchr(name, '/');
if (!p) p = strrchr(name, '\\');
if (p) name = p + 1;
if (*name == '\0' || strcmp(name, "..") == 0 || strcmp(name, ".") == 0)
    name = (char *)"unnamed";

name_static = (char *)malloc(strlen(name) + 1);
if (!name_static) {
    qFatal("out of memory");
}
memcpy(name_static, name, strlen(name) + 1);  // sanitized name
zi->fname = name_static;

Breaking Down the Fix

Let's walk through each defensive step:

1. Strip Path Separators (Both Unix and Windows)

p = strrchr(name, '/');
if (!p) p = strrchr(name, '\\');
if (p) name = p + 1;

strrchr() finds the last occurrence of the path separator character. By taking everything after the last separator (p + 1), we effectively call the equivalent of basename() — extracting only the final component of the path.

For example:
- ../../.ssh/authorized_keys → authorized_keys
- /etc/cron.d/backdoor → backdoor
- ..\..\AppData\malware.exe → malware.exe

Checking both / and \ is important for cross-platform robustness, especially since ZMODEM senders may run on different operating systems.

2. Reject Degenerate Filenames

if (*name == '\0' || strcmp(name, "..") == 0 || strcmp(name, ".") == 0)
    name = (char *)"unnamed";

After stripping separators, we must handle edge cases:
- An empty string (e.g., if the original name was just / or \)
- The literal string .. (parent directory reference)
- The literal string . (current directory reference)

These are all replaced with the safe fallback "unnamed".

3. memcpy Instead of strcpy

memcpy(name_static, name, strlen(name) + 1);

The fix also replaces strcpy() with memcpy(). While both are functionally equivalent for null-terminated strings when the destination is correctly sized, memcpy() with an explicit length is considered a better practice — it makes the intent explicit and is less prone to misuse in future refactoring. (Note: strncpy() or strlcpy() would also be reasonable choices here.)

4. calloc Instead of malloc for Pathname Buffer

pathname = (char *)calloc(PATH_MAX, 2);

The malloc call for the pathname buffer was replaced with calloc, which zero-initializes the allocated memory. This eliminates the possibility of uninitialized memory being used as part of a path if subsequent string operations don't fill the entire buffer.

5. snprintf Instead of strcpy for Pathname Construction

snprintf(pathname, PATH_MAX * 2, "%s", name_static);

The final strcpy into pathname is replaced with snprintf, which respects the buffer size limit and prevents potential buffer overflows if the sanitized name somehow exceeds the expected length.


Conclusion

A single missing call to basename() — a function that's been in POSIX for decades — was enough to create a critical, filesystem-level vulnerability in a ZMODEM receiver. The attack requires no special privileges, no complex exploit chain, and no memory corruption: just a crafted filename and a receiver that trusts it.

The fix is elegant in its simplicity: strip path separators, reject degenerate names, use bounded string functions, and zero-initialize buffers. These are not exotic techniques — they're basic defensive programming practices that every developer working with file paths and remote input should have in their toolkit.

Key takeaways:

  • 🚨 Remote-supplied filenames are untrusted input. Always sanitize them before use in filesystem operations.
  • 🛡️ basename() and realpath() are your friends. Use them together for robust path validation.
  • 📏 Prefer bounded string functions (snprintf, strlcpy, memcpy with explicit sizes) over unbounded ones (strcpy, strcat).
  • 🔍 Static analysis tools can catch these vulnerabilities automatically — integrate them into your CI/CD pipeline.
  • 🔒 Least privilege matters. Even if a traversal occurs, limiting the process's filesystem permissions reduces the blast radius.

File transfer code is often old, battle-tested, and rarely touched — which is exactly why it deserves a fresh security review. The next time you're working with any code that writes files based on externally supplied names, ask yourself: what happens if that name is ../../etc/passwd?


This vulnerability was identified and fixed by automated security scanning. Automated tools can catch entire classes of vulnerabilities like path traversal consistently and at scale — making them an essential complement to manual code review.

Prevention and further reading

Related Articles

high

clean_path() Path Traversal: rel_path Escapes USERDATA Dir

A path traversal flaw in the `clean_path()` helper let a user-controlled `rel_path` value escape the intended USERDATA directory and reach arbitrary files on disk. The function joined path segments without checking the final result, so `../` sequences in route parameters or query strings could be used to read or write files outside the sandboxed storage area. The fix normalizes the path and verifies it still resolves inside the USERDATA root before returning it, raising an error otherwise.

critical

path.resolve() Path Traversal in Node CLI's Dynamic import()

A Node.js CLI script for validating expression definitions took a file path from `process.argv[2]`, resolved it with `path.resolve()`, and passed the result straight into a dynamic `import()` — with no check that the resolved path stayed inside the working directory. An attacker (or a malicious skill/plugin invocation) could supply traversal sequences to load and execute arbitrary `.js` files from anywhere on disk. The fix adds a boundary check with `path.relative()` and an extension allowlist b

high

bookDir() Path Traversal via Unsanitized bookId Parameter

The `bookDir()` function accepted unsanitized `bookId` values derived from user-created book titles, enabling path traversal attacks through `../` sequences. A fix was applied that validates the identifier using `path.basename()` and throws on mismatch, ensuring all resolved paths remain within `LIBRARY_DIR`.

high

Express `app.get('*')` Wildcard Handler Path Traversal in watch.js

A first-party Express server's wildcard route handler used `req.url.indexOf('font.woff2')` to gate access to a font file, allowing attackers to bypass the substring check with crafted paths. The fix replaces the catch-all handler with explicit route registration.

high

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

high

markitdown_bridge.py Path Traversal: Arbitrary File Read via sys.argv

The markitdown_bridge.py script, used by MDView for DOCX-to-Markdown conversion, accepted file paths directly from command-line arguments without validating they stayed within intended directories. An attacker could exploit this to read arbitrary files from the filesystem by passing path traversal sequences in the source_path parameter.