Symlink Attack via Predictable /tmp Statistics File — Arbitrary File Overwrite as Root
- CVE
- CVE-2026-48693
- CVSS
- 7.0 (High)
- CWE
- CWE-59 (Improper Link Resolution Before File Access), CWE-377 (Insecure Temporary File)
- Affected
- FastNetMon Community Edition <= 1.2.9
- Component
- src/fastnetmon.cpp line 159 (path); src/fastnetmon_logic.cpp lines 2184-2196 (write); src/fastnetmon.cpp line 1821 (
umask(0)) - Attack Vector
- Local
- Discovered by
- Lorikeet Security
FastNetMon periodically dumps its current per-subnet statistics to a file so that operators and external scripts can read the current state without going through the gRPC API. The default file path is hardcoded as /tmp/fastnetmon.dat. The function print_screen_contents_into_file() opens this path with C++'s std::ofstream in std::ios::trunc mode (truncating any existing content) and writes the latest statistics.
Three independent flaws combine to make this a classical symlink attack:
- The path is predictable (always
/tmp/fastnetmon.dat) and in a world-writable directory. - The open uses
std::ofstreamwithout settingO_NOFOLLOW, so symlinks at that path are followed. - The daemon calls
umask(0)during daemonization, so any file the daemon creates gets the world-writable permission bits in its mode argument applied verbatim.
A bonus fourth issue compounds the impact: the chmod() call that's supposed to harden the stats file after creation always operates on the hardcoded cli_stats_file_path variable, ignoring the file_path parameter that the function actually received. So calling the function with a different path applies the wrong-file's permissions.
Disclosure status: Lorikeet Security notified FastNetMon LTD on April 25, 2026. CVE-2026-48693 was assigned by MITRE. No vendor response or fix as of May 23, 2026.
The vulnerable code
// src/fastnetmon.cpp around line 159 -- the predictable path
std::string cli_stats_file_path = "/tmp/fastnetmon.dat";
// src/fastnetmon_logic.cpp around lines 2184-2196 -- the symlink-following write
void print_screen_contents_into_file(const std::string& file_path,
const std::string& data) {
std::ofstream screen_data_file;
screen_data_file.open(file_path.c_str(), std::ios::trunc);
// ^^^ Symlinks at file_path are followed. No O_NOFOLLOW.
screen_data_file << data;
screen_data_file.close();
chmod(cli_stats_file_path.c_str(), // <-- BUG: ignores file_path parameter
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH);
// ^^^ Includes S_IROTH (world-readable) despite a code comment claiming '660'.
}
// src/fastnetmon.cpp around line 1821 -- the dangerous umask
umask(0);
// ^^^ Makes any file the daemon subsequently creates world-writable
// (modulo whatever permission bits the calling code specifies, but
// std::ofstream defaults to 0666 which umask(0) leaves intact).
Three errors in one function. The classical pattern is well-documented — this is exactly the bug shape that produced major CVEs in at, cron, logrotate, and dozens of other Unix daemons over the past three decades.
The attack, step by step
- Attacker creates a symlink. A local user (any user, even unprivileged) runs
ln -s /etc/cron.d/payload /tmp/fastnetmon.dat./etc/cron.d/payloaddoesn't need to exist yet; the symlink is just a forwarder. - Wait for FastNetMon to refresh its stats. The daemon runs
print_screen_contents_into_file()periodically (every few seconds, depending on configuration). On the next refresh,std::ofstream::open("/tmp/fastnetmon.dat", std::ios::trunc)follows the symlink, creates/etc/cron.d/payloadas a new file (becausestd::ios::trunccreates the file if it doesn't exist), and truncates it. - The daemon's stats data gets written to the target file. Whatever FastNetMon was about to write — per-subnet traffic counters, ban list status, internal state — gets dumped into the target path.
- The file is now world-writable because of
umask(0). The chmod addsS_IROTHon top. - The attacker writes whatever they want. The cron file (or any other file the attacker pointed the symlink at) is now writable by them. They populate it with a malicious cron entry. cron picks it up on the next minute and runs the attacker's payload as root.
This is a clean local-privilege-escalation primitive. FastNetMon typically runs as root (because it needs CAP_NET_RAW for packet capture and the default install does not split capabilities), so the daemon's writes have root permissions. Any file under /etc, /var, /usr — anywhere the root user can write — is a potential target.
High-value targets
/etc/cron.d/— entries here run on the next cron interval as root./etc/sudoers.d/— entries here are read by sudo; a malicious sudoers fragment gives the attacker passwordless root./root/.ssh/authorized_keys— the attacker's SSH key appended here gives them root SSH access./etc/passwdor/etc/shadow— truncation alone breaks authentication; the rebuilt content could include a backdoor user./var/lib/snapd/seed/seed.yamlor other configuration management state files — corrupting these can cause system services to misbehave on next reboot in attacker-influenced ways.
How a fix should look
// 1. Move the stats file to a daemon-private directory.
constexpr const char* STATS_DIR = "/var/lib/fastnetmon";
// systemd unit creates this with mode 0750, owner fastnetmon:fastnetmon.
// 2. Open with O_NOFOLLOW | O_CLOEXEC and explicit mode.
int fd = open((std::string(STATS_DIR) + "/stats.dat").c_str(),
O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC,
0640);
if (fd < 0) {
// If errno == ELOOP, a symlink was at the path: bail and log a security warning.
log_error("stats file open failed (possibly symlink): %s", strerror(errno));
return;
}
// 3. Set a sane umask in daemonize(), not 0.
umask(0027); // owner full, group read, world nothing.
// 4. Write through the fd, close it, no chmod games.
write(fd, data.data(), data.size());
close(fd);
Four changes: dedicated private directory, explicit O_NOFOLLOW, sane umask, no post-creation chmod (the open's mode argument is the source of truth). Each change closes one of the four failure modes; all four together produce a write that cannot be redirected by a symlink, cannot create world-writable files, and cannot apply the wrong permissions.
The chmod bug (always using cli_stats_file_path instead of the file_path parameter) is a separate fix — even after the other improvements, the function should use its parameter, not a hardcoded module-level variable.
Compensating controls
- Mount
/tmpwithnosymfollow(Linux 5.10+). The kernel refuses to follow symlinks within mount points that have this flag, defeating this attack across all programs that use/tmp. Many distributions already do this in their default systemd-tmpfiles setup. - Enable
fs.protected_symlinks=1insysctl. This is a Linux kernel feature that prevents one user from following a symlink owned by another user in a world-writable directory. It defaults to1on most distributions, but verify withsysctl fs.protected_symlinks. - Don't run FastNetMon as root. The whole impact ladder of this CVE assumes a root-owned write. A FastNetMon running as a dedicated
fastnetmonuser with onlyCAP_NET_RAWon the binary cannot overwrite root-owned files. Set the user in the systemd unit and grant capabilities on the binary withsetcap cap_net_raw=eip /usr/sbin/fastnetmon. - Change the stats file path. Set
cli_stats_file_pathinfastnetmon.confto a path under/var/lib/fastnetmon/instead of leaving it at the/tmpdefault. Create the directory with appropriate ownership and permissions. - Lock down
/tmpto only allow the daemon user to create files there. This is heavy-handed but possible with namespace isolation.
The pattern: /tmp is hostile shared state
Symlink attacks on predictable /tmp paths are one of the oldest local-privilege-escalation patterns in Unix. They have been documented since at least 1989 (the "race tmpfile" family of CERT advisories). Every few years a new daemon ships with the same shape: predictable path, truncating open, missing O_NOFOLLOW, umask too permissive. Each of those is a separately-correctable problem, but the combination produces a clean LPE primitive.
The structural fix for new code is to never write to /tmp from a privileged process. Use a private directory created with the daemon's expected ownership and mode, ideally one that's bind-mounted into the daemon's namespace if you're running under modern systemd. Reserve /tmp for user-shell scratch space and unprivileged programs.
For existing code that already writes to /tmp, the minimum fix is the O_NOFOLLOW flag. It's one constant added to the open call, and it shuts down the symlink-redirection vector entirely. std::ofstream doesn't directly support O_NOFOLLOW, but you can call open() with the flag and then wrap the resulting fd with fdopen() + __gnu_cxx::stdio_filebuf, or just use write() directly.
Disclosure timeline
| Date | Event |
|---|---|
| 2026-04-25 | Vulnerability identified during Lorikeet Security source code audit of FastNetMon Community Edition 1.2.9 |
| 2026-04-25 | CVE ID requested from MITRE |
| 2026-04-25 | Vendor (Pavel Odintsov / FastNetMon LTD) notified at the contact published in SECURITY.md |
| 2026-05-22 | CVE-2026-48693 assigned by MITRE |
| TBD | Vendor response |
| TBD | Fix release |
| 2026-05-23 | Lorikeet Security publishes responsible disclosure report |
Full Responsible Disclosure Report (PDF)
Complete writeup of all 16 FastNetMon Community Edition vulnerabilities Lorikeet Security identified, including vulnerable-code excerpts, impact analysis, and remediation guidance for each CVE.
Auditing the network infrastructure your business depends on
Lorikeet Security finds the parser bugs, protocol confusion, and unauthenticated-control-plane issues that move your DDoS detector, your BGP speaker, and your edge devices from "running" to "exploitable." Source code review, fuzz harness development, and adversarial protocol testing.