Path Traversal in ZMODEM Receiver: How a Missing basename() Call Could Overwrite Your SSH Keys
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.
Prevention & Best Practices
This vulnerability is a textbook example of trusting remote input. Here's how to avoid it in your own code:
1. Never Trust Filenames from Remote Sources
Any filename that originates outside your process — from a network peer, an API, a file upload, a database — must be treated as hostile input until proven otherwise.
2. Use basename() or Equivalent
In C/C++, basename() (POSIX) extracts the final component of a path. Always apply it (or an equivalent) to remote-supplied filenames before using them in path construction:
#include <libgen.h>
char *safe_name = basename(remote_filename);
⚠️ Note:
basename()modifies its argument on some platforms. Use a copy, and be aware of platform differences between POSIX and GNU behavior.
3. Validate the Resolved Path with realpath()
After constructing the full output path, use realpath() to resolve all symlinks and .. components, then verify the result starts with your intended base directory:
char resolved[PATH_MAX];
if (realpath(output_path, resolved) == NULL) {
// path doesn't exist yet — handle carefully
}
if (strncmp(resolved, allowed_base_dir, strlen(allowed_base_dir)) != 0) {
// path escapes the allowed directory — reject it
error("Path traversal detected!");
}
4. Allowlist Valid Filename Characters
Consider restricting filenames to a known-safe character set (alphanumerics, hyphens, underscores, dots) using a regex or character-by-character validation:
bool is_safe_filename(const char *name) {
for (const char *p = name; *p; p++) {
if (!isalnum(*p) && *p != '-' && *p != '_' && *p != '.') {
return false;
}
}
return strlen(name) > 0 && strcmp(name, "..") != 0 && strcmp(name, ".") != 0;
}
5. Use Safer String Functions
Replace strcpy() with bounded alternatives:
- strlcpy() (BSD/macOS, or via a compatibility library)
- snprintf(dst, size, "%s", src)
- strncpy() (with care — it doesn't guarantee null-termination)
6. Apply the Principle of Least Privilege
File receivers should run with the minimum privileges necessary. If the receiver only needs to write to a specific download directory, it should not run as root or with write access to sensitive system files.
7. Use Static Analysis Tools
Tools like these can catch path traversal vulnerabilities during development:
- Coverity — detects tainted data flowing into file operations
- CodeQL — GitHub's semantic code analysis engine (has path traversal queries)
- Semgrep — customizable pattern-based scanner
- Flawfinder — lightweight C/C++ scanner that flags dangerous function calls like strcpy
Relevant Security Standards
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
- CWE-73: External Control of File Name or Path
- OWASP: Path Traversal
- OWASP Testing Guide: OTG-AUTHZ-001 (Directory Traversal)
- CERT C: FIO02-C (Canonicalize path names originating from tainted sources)
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()andrealpath()are your friends. Use them together for robust path validation. - 📏 Prefer bounded string functions (
snprintf,strlcpy,memcpywith 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.