Skip to content

security: harden session directory, stale-socket unlink, and clear path#38

Open
babs wants to merge 3 commits into
mobydeck:mainfrom
babs:security/hardening-tmp-and-clear
Open

security: harden session directory, stale-socket unlink, and clear path#38
babs wants to merge 3 commits into
mobydeck:mainfrom
babs:security/hardening-tmp-and-clear

Conversation

@babs
Copy link
Copy Markdown

@babs babs commented May 18, 2026

Three related local-attack hardenings, one per commit. Each is independent and small enough to revert in isolation.

1. Validate session directory ownership and mode

expand_sockname() calls mkdir(dir, 0700) and ignores the return value. On the /tmp/.<progname>-<uid> fallback path (taken when \$HOME is unset or /), a hostile local user can pre-create that directory with permissive modes (or as a symlink) before the victim runs atch — every subsequent socket and .log lands under attacker control.

Fix: after mkdir, lstat the directory and refuse to proceed unless it is a real directory (not a symlink) owned by the current uid with mode 0700.

Also adds O_NOFOLLOW to the master's session-log open. Defense in depth: with the directory check in place the dir contents are already user-only, but O_NOFOLLOW also blocks any same-uid process that plants a symlink between mkdir and open.

2. Stat before unlinking stale session socket

cmd_open and the legacy -A mode call unlink(sockname) unconditionally when connect() returns ECONNREFUSED. With the directory check this is no longer cross-user-reachable, but the TOCTOU between the failed connect and the unlink is still a sharp edge.

Fix: lstat the path and unlink only if it's really a unix socket owned by the current uid.

3. Copy session name from env in cmd_clear, O_NOFOLLOW the log

cmd_clear stored a const char * returned by getenv() into the global char *sockname via a cast that drops const — undefined behavior if any future code path mutates sockname. Duplicate the chosen ancestry segment into owned storage instead.

Open the log with O_NOFOLLOW for the same reason as commit 1: refuse to truncate via a symlink.

Test plan

  • make clean (no new warnings)
  • sh tests/test.sh ./atch — 228/229 (the one failing test fails on main too; unrelated)
  • Manual negative tests pass:
    • Pre-create ~/.cache/atch mode 0755 → atch refuses with clear message
    • Plant ~/.cache/atch/x.log/tmp/target symlink, run atch clear x → refused (ELOOP), target untouched
    • Plant ~/.cache/atch/x.log/tmp/target symlink, run atch start x … → log open silently disabled (existing best-effort behavior), target untouched

Notes

  • No new dependencies, no behavior change for the normal \$HOME/.cache/<progname>/ path with default mode 0700.
  • Existing setups with a wider mode on the session dir will start refusing — that's the intended security boundary, but worth calling out for downstream packagers.
  • Three other findings from the same audit (arbitrary signal via MSG_KILL, raw escape replay on attach/tail, tail -f blind to log rotation) are not included here; happy to send follow-up PRs if you want them.

babs added 3 commits May 18, 2026 12:34
Verify the session directory ($HOME/.cache/<progname> or the
/tmp/.<progname>-<uid> fallback) is a real directory owned by the current
uid with mode 0700 after mkdir(). The mkdir() return value was previously
ignored, so on the /tmp fallback path a hostile local user could
pre-create /tmp/.<progname>-<uid>/ with permissive modes (or as a symlink)
and intercept sockets and logs written by the victim.

Also open the session log with O_NOFOLLOW so a pre-placed symlink at
<sockname>.log cannot redirect master writes into an arbitrary file.
cmd_open() and the legacy -A mode unlink sockname unconditionally when
connect() returns ECONNREFUSED. With the session directory now validated
as user-owned 0700 this is no longer reachable cross-user, but defense in
depth is cheap: lstat the path and only unlink when it really is a unix
socket owned by the current uid, closing the TOCTOU between the failed
connect and the unlink.
cmd_clear stored a const pointer returned by getenv() into the global
char *sockname via a cast that drops const — undefined behavior if any
future code path mutates sockname, and fragile in general. Duplicate the
chosen ancestry segment into owned storage instead.

Open the log with O_NOFOLLOW so a pre-placed symlink at <sockname>.log
cannot redirect the truncate onto an arbitrary user-writable file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant