shared.Mkdir: os.Stat follows symlinks, allows redirect of cert file writes #36

Closed
opened 2026-05-18 08:32:28 +02:00 by heiko · 2 comments
Owner

Summary

internal/shared/mkdir.go:13 uses os.Stat (symlink-following) in the EEXIST fallback:

err := os.Mkdir(dir, 0777)
if err != nil && os.IsExist(err) {
    stat, err := os.Stat(dir)   // follows symlinks
    if err != nil { return err }
    if stat.IsDir() { return nil }
}

Between os.Mkdir returning EEXIST and os.Stat running, a local attacker with write access to the certbase parent can replace the domain directory with a symlink pointing to an arbitrary directory. os.Stat follows the symlink, sees a directory, and returns nil. All subsequent cert and private key writes go to the attacker-controlled path.

Fix

Use os.Lstat instead of os.Stat so the check applies to the directory entry itself, not its target:

stat, err := os.Lstat(dir)

Or replace the whole function with os.MkdirAll, which is race-safe for the common case.

## Summary `internal/shared/mkdir.go:13` uses `os.Stat` (symlink-following) in the EEXIST fallback: ```go err := os.Mkdir(dir, 0777) if err != nil && os.IsExist(err) { stat, err := os.Stat(dir) // follows symlinks if err != nil { return err } if stat.IsDir() { return nil } } ``` Between `os.Mkdir` returning EEXIST and `os.Stat` running, a local attacker with write access to the certbase parent can replace the domain directory with a symlink pointing to an arbitrary directory. `os.Stat` follows the symlink, sees a directory, and returns nil. All subsequent cert and private key writes go to the attacker-controlled path. ## Fix Use `os.Lstat` instead of `os.Stat` so the check applies to the directory entry itself, not its target: ```go stat, err := os.Lstat(dir) ``` Or replace the whole function with `os.MkdirAll`, which is race-safe for the common case.
Author
Owner

Confirmed. internal/shared/mkdir.go:13 does os.Stat, which follows symlinks. An attacker with write access to the certbase parent can replace a domain directory with a symlink between the os.Mkdir EEXIST return and the os.Stat call; cert and private key writes then go to the symlink target.

Fix: os.Lstat — one-liner, makes the stat apply to the entry itself rather than its target. If a symlink is sitting there, stat.IsDir() returns false and we bubble an error rather than silently writing through it.

os.MkdirAll is an alternative but changes the semantics (creates intermediate dirs, not just one level); keeping os.Mkdir + os.Lstat is the minimal correct fix.

🤖 Generated with Claude Code (claude-sonnet-4-6)

Confirmed. `internal/shared/mkdir.go:13` does `os.Stat`, which follows symlinks. An attacker with write access to the certbase parent can replace a domain directory with a symlink between the `os.Mkdir` EEXIST return and the `os.Stat` call; cert and private key writes then go to the symlink target. Fix: `os.Lstat` — one-liner, makes the stat apply to the entry itself rather than its target. If a symlink is sitting there, `stat.IsDir()` returns false and we bubble an error rather than silently writing through it. `os.MkdirAll` is an alternative but changes the semantics (creates intermediate dirs, not just one level); keeping `os.Mkdir` + `os.Lstat` is the minimal correct fix. — 🤖 Generated with Claude Code (claude-sonnet-4-6)
Author
Owner

AI attribution comment added per repository instruction for this open issue.\n\n(co)authored by ai:gpt-5-codex

AI attribution comment added per repository instruction for this open issue.\n\n(co)authored by ai:gpt-5-codex
heiko closed this issue 2026-05-24 14:09:18 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
heiko/cert-proxy#36
No description provided.