Secure Coding in C · intermediate · ~15 min
- Explain what a symbolic link is, how the kernel resolves it, and why that resolution is dangerous for privileged code. - Recognise the shape of a symlink attack: an attacker plants a link where your program expects a real file. - Use `O_NOFOLLOW` and `O_CLOEXEC` to refuse symlinks when opening untrusted paths. - Create temporary files safely with `mkstemp` instead of predictable, race-prone names. - Canonicalise a path with `realpath` and enforce that it stays inside an allowed root directory. - Audit `open(..., O_CREAT, ...)` calls on world-writable directories like `/tmp` for missing defences.
A symbolic link (symlink) is a tiny special file whose entire content is another path. When you open a symlink, the kernel silently redirects you to whatever path the link points at. That redirection is invisible to your program — open("/tmp/report", ...) may quietly open /etc/shadow if /tmp/report is a symlink an attacker planted.
A symlink attack exploits exactly this. An attacker who can write to a directory your program touches (very often /tmp) replaces an expected file with a symlink aimed at a sensitive target. If your program runs with more privilege than the attacker — a setuid tool, a root daemon, a CI runner — it follows the link with its own privileges and reads or clobbers a file the attacker could never touch directly.
This lesson builds directly on two prerequisites. From TOCTOU — time-of-check vs time-of-use races you already know that the gap between checking a file and using it is a window an attacker can slip through; symlink attacks are the classic way that window gets abused. From fopen and the stdio model you know how C programs open files; here you drop below stdio to the raw open() syscall, because that is where the symlink-refusing flags live.
In plain terms: the kernel trusts symlinks by default, so you must be the one who says "no, do not follow that." The tools for saying no are O_NOFOLLOW, mkstemp, and realpath plus a prefix check.
The victims of symlink attacks are almost always programs that run with more authority than the person who controls the filesystem around them:
/tmp, /var/tmp, or /var/spool.When such a program follows an attacker's symlink, the consequences are severe and concrete:
/etc/passwd, a cron file, or an SSH authorized_keys, handing the attacker root.This is not a theoretical bug class. Symlink and the closely related TOCTOU races have produced CVEs in package managers, log rotators, and system daemons for decades. The defences are cheap — a flag here, the right function there — so there is no excuse for shipping code that follows symlinks blindly in a privileged context.
A symlink is a filesystem object that stores a path string. During path resolution the kernel walks each component of a path; when a component is a symlink, the kernel substitutes the link's target and keeps walking. This happens for every symlink along the path, not just the last one.
You ask to open: /tmp/report
/tmp/report --is a symlink--> /etc/shadow
Kernel resolves it to: /etc/shadow
Your open() returns a fd on /etc/shadow (you never asked for it)
When it bites you: any time your program opens a path that lives in, or passes through, a directory a less-trusted user can write to.
Pitfall: thinking "I created this file a moment ago, so it is mine." Between your check and your open, an attacker can unlink your file and drop a symlink in its place.
Knowledge check: In your own words, why can a stat("/tmp/f") that reports a regular file still open a symlink milliseconds later?
O_NOFOLLOW — refuse a symlink at open timePass O_NOFOLLOW as a flag to open(). If the final component of the path is a symlink, open() fails immediately with errno == ELOOP and never opens the target.
open("/tmp/report", O_RDONLY | O_NOFOLLOW)
/tmp/report is a symlink -> open() returns -1, errno = ELOOP
/tmp/report is a real file -> open() succeeds
Critical limit: O_NOFOLLOW only guards the last component. If /tmp itself, or an intermediate directory, is a symlink, it is still followed. For full-path safety you either canonicalise first (realpath) or open each directory step with openat(..., O_NOFOLLOW) — the O_NOFOLLOW-per-component pattern.
When to use: whenever you open a user-influenced path and a symlink there would be surprising or dangerous.
When NOT to: when following a symlink is the intended feature (e.g. a user's own config that they legitimately symlinked). Refuse based on trust boundary, not reflex.
Pitfall: checking if (fd < 0) but ignoring errno, so you cannot tell "symlink refused" from "file missing."
Knowledge check (predict the output): /tmp/link is a symlink to /etc/hostname. What does open("/tmp/link", O_RDONLY | O_NOFOLLOW) return, and what is errno?
mkstemp — atomic, unpredictable temp filesHand-rolled temp files pick a name first (/tmp/app.tmp) and create it later. That two-step is a TOCTOU race: an attacker predicts or observes the name and plants a symlink before your open.
mkstemp closes the window. It takes a writable template ending in six X characters, replaces the Xs with random characters, and creates and opens the file in a single atomic step using O_CREAT | O_EXCL | O_RDWR. O_EXCL guarantees the call fails if the name already exists — so an attacker's pre-planted symlink cannot be reused.
| Approach | Name chosen | Create step | Symlink-safe? |
|---|---|---|---|
tmpnam / mktemp / manual |
predictable, separate | later, non-atomic | No — race window |
mkstemp |
random suffix | atomic O_CREAT|O_EXCL |
Yes |
Pitfall: passing a string literal as the template. mkstemp rewrites the template in place; a literal is read-only and the program crashes.
Knowledge check (find the bug): char *t = "/tmp/app-XXXXXX"; int fd = mkstemp(t); — what is wrong, and how do you fix it?
realpath + prefix check — confine to an allowed rootrealpath(path, buf) resolves a path to its canonical absolute form: it follows every symlink and removes . and ... After resolving, you compare the result against your allowed root directory. If the canonical path does not start with "/var/data/" (including the trailing slash), you reject it.
untrusted input: /var/data/../../etc/passwd
realpath --> /etc/passwd
prefix check: starts with "/var/data/"? NO -> reject
When to use: when you accept a filename from an untrusted source and must guarantee it stays inside a sandbox directory.
Pitfall (a real one): realpath resolves symlinks at the moment it runs. Using the returned string to open later reintroduces a TOCTOU gap — the attacker can swap a link in between. Prefer opening via file descriptors (openat) or re-validating, and treat realpath as one layer, not the whole defence.
Second pitfall: checking strncmp(resolved, "/var/data", 9) without the trailing slash — that also accepts /var/database. Always include the separator.
O_DIRECTORY and the pentester's audit habitO_DIRECTORY makes open() fail unless the target is a directory — useful when you assume a path is a directory and want the kernel to enforce it. Combined with O_NOFOLLOW it prevents "I thought this was my scratch dir but it was a symlink to /."
The audit mindset: any privileged create/write to a user-writable directory is a symlink-attack candidate. Walk the checklist:
| Directory | Who can write | Must every O_CREAT use O_EXCL / mkstemp? |
|---|---|---|
/tmp, /var/tmp |
everyone | Yes |
/dev/shm |
everyone | Yes |
/var/spool/... |
often group-writable | Yes |
/var/data (your own) |
your service only | Still prefer it |
Defensive default: open untrusted paths with O_NOFOLLOW | O_CLOEXEC. O_CLOEXEC also closes the descriptor across exec, so a child process cannot inherit a fd it should not have.
#include <fcntl.h> /* open, O_NOFOLLOW, O_CLOEXEC, O_DIRECTORY */
#include <stdlib.h> /* mkstemp, realpath */
#include <errno.h>
/* Refuse a symlink on the final component; also close-on-exec. */
int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0 && errno == ELOOP) { /* last component is a symlink */ }
/* Atomic, unpredictable temp file. Template MUST be a writable array
ending in exactly six X's. mkstemp rewrites it in place. */
char tmpl[] = "/tmp/app-XXXXXX";
int tfd = mkstemp(tmpl); /* tmpl now holds the real name */
/* Canonicalise then confine. PATH_MAX-sized buffer. */
char resolved[PATH_MAX];
if (realpath(user_path, resolved) != NULL) { /* prefix-check resolved */ }
O_NOFOLLOW guards only the last path component; intermediate symlinks are still followed.mkstemp needs exactly six trailing X characters and a writable buffer.realpath writes up to PATH_MAX bytes; passing a NULL output buffer makes it malloc (you must free).A symlink (created with ln -s) is a small file whose contents are simply another path. Opening it transparently redirects you to that target.
Attackers abuse this by pointing a symlink at a file they want you to read or write. When your code runs with elevated permissions, this becomes a classic privilege-escalation primitive.
The defense has three parts:
O_NOFOLLOW to refuse symlinks on open.mkstemp.realpath plus a prefix check to confirm the resolved path stays inside the allowed root.#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
/* Allowed sandbox root. A confined path must resolve to inside this. */
#define ALLOWED_ROOT "/tmp/cplat-data/"
/* Open an untrusted path while refusing a symlink on the final
component. Returns a file descriptor, or -1 on any failure. */
static int open_no_symlink(const char *path) {
int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0) {
if (errno == ELOOP)
fprintf(stderr, "refused: '%s' is a symlink\n", path);
else
perror("open");
}
return fd;
}
/* Canonicalise a user path and confirm it stays under ALLOWED_ROOT.
Returns 1 if allowed, 0 if it escapes or cannot be resolved. */
static int inside_allowed_root(const char *user_path) {
char resolved[PATH_MAX];
if (realpath(user_path, resolved) == NULL) {
perror("realpath");
return 0; /* cannot resolve -> reject */
}
/* Trailing slash in ALLOWED_ROOT prevents matching /tmp/cplat-dataX */
size_t rootlen = strlen(ALLOWED_ROOT);
return strncmp(resolved, ALLOWED_ROOT, rootlen) == 0;
}
/* Create a private temp file atomically and return its fd (or -1). */
static int make_temp(char *name_out, size_t cap) {
char tmpl[] = "/tmp/cplat-XXXXXX"; /* writable array, 6 X's */
int fd = mkstemp(tmpl);
if (fd < 0) { perror("mkstemp"); return -1; }
snprintf(name_out, cap, "%s", tmpl); /* tmpl now holds real name */
return fd;
}
int main(int argc, char **argv) {
/* 1. Demonstrate safe temp-file creation. */
char tmpname[PATH_MAX];
int tfd = make_temp(tmpname, sizeof tmpname);
if (tfd < 0) return EXIT_FAILURE;
const char *msg = "scratch data\n";
if (write(tfd, msg, strlen(msg)) < 0) perror("write");
printf("created temp file: %s\n", tmpname);
close(tfd);
unlink(tmpname); /* clean up */
/* 2. Open a caller-supplied path, refusing symlinks. */
if (argc >= 2) {
if (!inside_allowed_root(argv[1])) {
fprintf(stderr, "refused: '%s' escapes %s\n",
argv[1], ALLOWED_ROOT);
return EXIT_FAILURE;
}
int fd = open_no_symlink(argv[1]);
if (fd < 0) return EXIT_FAILURE;
char buf[128];
ssize_t n = read(fd, buf, sizeof buf - 1);
if (n >= 0) { buf[n] = '\0'; printf("read: %s", buf); }
close(fd);
}
return EXIT_SUCCESS;
}
What it does. The program first shows the safe temp-file pattern: make_temp calls mkstemp on a writable template and returns an already-open descriptor, so there is no window for a symlink swap. It writes a line, then cleans up. If you pass a path argument, it canonicalises it with realpath, rejects anything outside /tmp/cplat-data/, and finally opens it with O_NOFOLLOW so a symlink on the last component is refused.
Expected output (no argument):
created temp file: /tmp/cplat-abc123
(the random suffix differs each run). If you run it with a symlink argument, you get refused: '<path>' is a symlink and a non-zero exit. If the argument escapes the root, you get refused: '<path>' escapes /tmp/cplat-data/.
Edge cases. realpath fails (returns NULL) if the path does not exist yet — deliberately, we then reject. mkstemp can fail if /tmp is full or unwritable. The PATH_MAX buffer assumes a bounded path length, which is standard on Linux.
Follow the no-argument run, then the argument run.
make_temp is called. char tmpl[] = "/tmp/cplat-XXXXXX"; allocates a writable 18-byte array on the stack — this is essential because mkstemp overwrites the Xs.mkstemp(tmpl) picks random characters for the six Xs, then atomically creates and opens the file with O_CREAT | O_EXCL | O_RDWR. If some file already had that name, O_EXCL makes it retry a new name; an attacker cannot force a collision onto a pre-planted symlink. It returns a descriptor, and tmpl now contains the real name, e.g. /tmp/cplat-Qy8fL2.snprintf(name_out, ...) copies that real name out so main can print and later unlink it.write / close / unlink exercise the file and tidy up. At this point no untrusted party ever saw a predictable name to attack.inside_allowed_root(argv[1]) runs. realpath walks the path, following any symlinks and collapsing .., producing a canonical absolute path in resolved.strncmp(resolved, ALLOWED_ROOT, rootlen) == 0 confirms the canonical path begins with /tmp/cplat-data/. Because the root includes a trailing slash, a sibling like /tmp/cplat-dataX is correctly rejected.open_no_symlink(argv[1]) calls open with O_NOFOLLOW. If the final component is a symlink, open returns -1 and sets errno == ELOOP; the branch prints refused: ... is a symlink.read fills a buffer, we NUL-terminate at buf[n], print it, and close the descriptor.| Step | Key variable | Value / effect |
|---|---|---|
after mkstemp |
tmpl |
/tmp/cplat-Qy8fL2 (random) |
after realpath |
resolved |
canonical, symlink-free path |
| prefix check | strncmp == 0 |
1 = allowed, 0 = reject |
open w/ O_NOFOLLOW |
errno |
ELOOP if last component is a link |
The result — a safe temp file and a symlink-refusing open — is produced because every file-touch either used an atomic create or an explicit refuse-symlink flag, leaving no check-then-use gap.
1. Predictable temp names (mktemp, tmpnam, or manual).
/* WRONG: name chosen now, file created later — race window */
char name[64];
sprintf(name, "/tmp/app-%d", getpid()); /* attacker can predict this */
int fd = open(name, O_WRONLY | O_CREAT, 0600);
Why wrong: between choosing the name and opening it, an attacker plants a symlink at that path; your open follows it into a sensitive file. Fix: use mkstemp, which creates and opens atomically with O_EXCL. Recognise it by any temp path built from a predictable value (PID, timestamp, fixed string).
2. Passing a string literal to mkstemp.
int fd = mkstemp("/tmp/app-XXXXXX"); /* WRONG: literal is read-only */
Why wrong: mkstemp writes into the template; a literal lives in read-only memory, so the program crashes (SIGSEGV). Fix: char tmpl[] = "/tmp/app-XXXXXX"; int fd = mkstemp(tmpl);.
3. Trusting O_NOFOLLOW for the whole path.
open("/tmp/attacker/dir/file", O_RDONLY | O_NOFOLLOW); /* only checks 'file' */
Why wrong: if /tmp/attacker or dir is a symlink, it is still followed. Fix: canonicalise with realpath and prefix-check, or open component by component with openat(..., O_NOFOLLOW).
4. Prefix check without a trailing separator.
if (strncmp(resolved, "/var/data", 9) == 0) { /* WRONG */ }
Why wrong: it also accepts /var/database/secret. Fix: compare against "/var/data/" including the slash. Recognise it whenever a directory prefix check omits the separator.
5. Ignoring errno after a failed open. If you only test fd < 0 you cannot distinguish "symlink refused" from "no such file," and you may log the wrong thing or retry insecurely. Always inspect errno (ELOOP, ENOENT, EACCES).
Reproduce the attack in a lab (never on a real target):
mkdir -p /tmp/cplat-data
ln -sf /etc/hostname /tmp/cplat-data/target # plant a symlink
./a.out /tmp/cplat-data/target # should be REFUSED
If your program prints the contents of /etc/hostname, O_NOFOLLOW is not active — check that the flag actually reached open and that you are not falling back to fopen.
Common compiler errors
O_NOFOLLOW undeclared — you forgot #include <fcntl.h>, or need #define _GNU_SOURCE / _POSIX_C_SOURCE before includes on some systems.PATH_MAX undeclared — add #include <limits.h>.Common runtime errors
mkstemp — you passed a string literal; use a writable array.mkstemp returns -1 with EINVAL — the template did not end in six Xs.realpath returns NULL with ENOENT — the path does not exist yet; that is expected for not-yet-created files, so decide whether to reject or to validate the parent directory instead.Logic errors
ALLOWED_ROOT might be missing the trailing slash mismatch, or the real path uses /private/tmp on macOS while you compare /tmp. Print resolved to see what realpath actually produced.Questions to ask when it misbehaves: Did the flag reach the syscall? What is errno exactly? What does realpath print for this input? Is an intermediate directory a symlink that O_NOFOLLOW never checked?
Template writability (correctness and safety). mkstemp rewrites its template in place, so it must point at writable storage. A string literal is undefined behaviour to modify and crashes in practice. Always use a char[] array.
Buffer bounds with realpath. The two-argument form writes up to PATH_MAX bytes into your buffer; a smaller buffer is a classic overflow. Size it char resolved[PATH_MAX];. If you pass NULL as the output, realpath allocates the buffer and you must free it — forgetting to is a leak.
Initialise and bound reads. After read, only bytes 0..n-1 are valid; NUL-terminate at buf[n] (with a buffer one byte larger than the read count) before treating it as a string, or you read uninitialised memory.
Descriptor ownership. Every successful open/mkstemp returns a descriptor you own; close it on every path, including error paths, or you leak descriptors. O_CLOEXEC additionally prevents a forked/execed child from inheriting a descriptor it should not have.
Security framing. The vulnerability shown in Mistakes #1 — predictable name, non-atomic create — is a real symlink/TOCTOU privilege-escalation primitive; it is labelled WRONG and paired with the mkstemp fix. Defensive practice: least privilege (drop privileges before touching shared dirs when you can), validate untrusted paths (realpath + prefix), and prefer safe atomic APIs (mkstemp, openat with O_NOFOLLOW) over hand-rolled sequences. All reproduction here is lab-only against files you own.
Concrete case. Log rotators and package managers have repeatedly shipped symlink bugs: a privileged process writes a fresh log or unpacks a file into a world-writable directory, an attacker pre-plants a symlink to /etc/..., and the write lands on a system file. The fix in each case is the same trio — atomic create with O_EXCL/mkstemp, O_NOFOLLOW on opens, and canonicalise-then-confine for untrusted paths. CI/CD runners face the identical risk when extracting untrusted archives as a build user.
Professional habits
mkstemp. Add O_NOFOLLOW | O_CLOEXEC to opens of any path you did not fully control. Check errno, not just the return value. Clean up (close, unlink) on every exit path.openat, fstatat) to close the residual TOCTOU gap that realpath alone leaves. Drop privileges (seteuid) around the file operation so a followed link cannot reach anything valuable. Prefer O_TMPFILE for anonymous temp files that never appear in the namespace at all. Write a regression test that plants a symlink and asserts your code refuses it, so the defence cannot silently regress.Beginner 1 — Safe temp file. Write a program that creates a temp file with mkstemp from the template "/tmp/cplat-XXXXXX", writes the line "hello\n" to it, prints the actual filename, then closes and unlinks it. Requirements: use a writable array; check the return value; report errors with perror. Concepts: mkstemp, atomic create.
Beginner 2 — Refuse a symlink. Write a program that takes a path argument and opens it with O_RDONLY | O_NOFOLLOW. If the open fails with errno == ELOOP, print refused: symlink; otherwise print the first line. Test: ln -sf /etc/hostname /tmp/link then run against /tmp/link. Expected: refused: symlink. Concepts: O_NOFOLLOW, errno.
Intermediate 1 — Confine to a root. Implement int inside_root(const char *user_path, const char *root) that uses realpath to canonicalise user_path and returns 1 only if the result is inside root (mind the trailing slash). Reject unresolved paths. Input: /tmp/cplat-data/../secret with root /tmp/cplat-data/ → 0. Concepts: realpath, prefix check.
Intermediate 2 — Audit report. Given a list of open() call descriptions (path + flags as strings), print for each whether it is safe (uses O_NOFOLLOW or O_EXCL, or is not in a world-writable dir) or risky. Treat /tmp, /var/tmp, /dev/shm as world-writable. Concepts: the audit checklist, string matching.
Challenge — Component-by-component open. Write int open_confined(const char *root_fd_path, const char *rel) that opens rel one component at a time using openat(dirfd, comp, O_NOFOLLOW | O_DIRECTORY) for directories and openat(dirfd, last, O_NOFOLLOW | O_RDONLY) for the final file, so that no intermediate symlink is ever followed. Requirements: split rel on /; reject any .. component; close every intermediate descriptor; return the final fd or -1. Hint: keep a running directory descriptor and openat from it. Concepts: openat, per-component O_NOFOLLOW, descriptor hygiene.
A symlink is a file that stores another path, and the kernel follows it automatically — including through intermediate directories. A symlink attack exploits that: an attacker plants a link where your privileged program expects a real file, redirecting a read or write onto a sensitive target and escalating privileges.
The three core defences: O_NOFOLLOW makes open fail with ELOOP if the last component is a symlink (it does not guard earlier components); mkstemp creates a temp file atomically with a random name and O_EXCL, closing the race that predictable names open; realpath plus a trailing-slash prefix check confines an untrusted path to an allowed root. Add O_CLOEXEC as a default and O_DIRECTORY when you require a directory.
Most common mistakes: predictable temp names, passing a string literal to mkstemp, trusting O_NOFOLLOW for the whole path, and prefix checks that omit the separator. Remember: check errno, prefer atomic and descriptor-based APIs (openat), and treat every privileged write to /tmp and friends as an attack surface until you have proven otherwise.