DEV Community

Bala Paranj
Bala Paranj

Posted on

7 Filesystem Attacks Your Go CLI is Vulnerable To — And How to Fix Them

Symlink swaps, path traversal, TOCTOU races, zip slip, partial writes, and permission leaks — practical defensive patterns for Go CLIs that read and write user-controlled paths.

Your Go CLI reads a config file, writes a report, creates a directory. Every one of those operations is an attack surface if the path comes from the user.

A user runs stave apply --observations ./data. What if ./data is a symlink to /etc/passwd? What if a snapshot filename contains ../../.ssh/authorized_keys? What if the output directory is swapped to a symlink between your pre-check and your write?

These aren't theoretical. They're the vulnerability classes that CVEs are made of: symlink following (CWE-61), path traversal (CWE-22), TOCTOU races (CWE-367), and zip slip (CWE-23).

Here are 7 filesystem attacks we found and fixed in a Go security CLI, with the exact defensive code for each.

1. Symlink Following — The Classic

The Attack

The user creates a symlink: ln -s /etc/shadow ./observations/snapshot.json. Your tool reads "snapshot.json" and follows the symlink to /etc/shadow. Or worse: the user creates ln -s /tmp ./output, and your tool writes its report into /tmp where any process can read it.

The Defense: CheckSymlinkSafety

// CheckSymlinkSafety verifies that neither the target path nor any of its
// ancestor directories are symlinks. Walks up to 16 parent levels.
func CheckSymlinkSafety(path string) error {
    absPath, err := filepath.Abs(path)
    if err != nil {
        return fmt.Errorf("resolve absolute path: %w", err)
    }

    current := absPath
    for i := 0; i < 16; i++ {
        info, err := os.Lstat(current)
        if err != nil {
            if os.IsNotExist(err) {
                current = filepath.Dir(current)
                continue
            }
            return fmt.Errorf("lstat %s: %w", current, err)
        }
        if info.Mode()&os.ModeSymlink != 0 {
            return fmt.Errorf("symlink detected at %s", current)
        }
        if current == filepath.Dir(current) {
            return nil // reached filesystem root
        }
        current = filepath.Dir(current)
    }
    return fmt.Errorf("path too deep: exceeded 16 ancestor checks")
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Uses Lstat (not Stat) — Stat follows symlinks and would miss them entirely.
  • Walks ancestors — a symlink at /home/user/project is just as dangerous as one at the target path.
  • Bounded walk (16 levels) — prevents infinite loops on pathological paths.
  • Returns error on exhaustion — the original version silently returned nil after 16 iterations, treating deep paths as safe. That was a bug.

The Bug We Found

The original CheckSymlinkSafety had a loop that walked up to 16 parent directories. If it exhausted the loop without finding an existing ancestor (on a very deep path), it returned nil — success. This meant a path with 17+ components would bypass the symlink check entirely. Fixed by returning an error after the loop exits.

2. TOCTOU Race — The Subtle One

The Attack

Thread 1 (your tool):          Thread 2 (attacker):
1. Lstat(path) → not a symlink
                                2. rm path; ln -s /etc/shadow path
3. os.Create(path) → writes to /etc/shadow
Enter fullscreen mode Exit fullscreen mode

Between your check (step 1) and your use (step 3), the attacker swaps the path. This is the Time-of-Check-Time-of-Use (TOCTOU) race.

The Defense: Post-Open Handle Verification

// verifyHandle confirms the opened file handle matches the pre-open Lstat.
// Detects symlink swaps between Lstat and Open.
func verifyHandle(f *os.File, path string) error {
    // Get the inode of what we actually opened
    handleInfo, err := f.Stat()
    if err != nil {
        return fmt.Errorf("stat opened handle: %w", err)
    }

    // Get the inode of what the path points to NOW (without following symlinks)
    pathInfo, err := os.Lstat(path)
    if err != nil {
        return fmt.Errorf("lstat path after open: %w", err)
    }

    // If the path is now a symlink, or points to a different inode,
    // something changed between our pre-check and our open.
    if pathInfo.Mode()&os.ModeSymlink != 0 {
        return fmt.Errorf("path became a symlink after open")
    }
    if !os.SameFile(handleInfo, pathInfo) {
        return fmt.Errorf("file handle does not match path (possible TOCTOU)")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The pattern: check before open, verify after open, close if mismatch.

func SafeCreateFile(path string, opts WriteOptions) (*os.File, error) {
    if !opts.AllowSymlink {
        if err := CheckSymlinkSafety(path); err != nil {
            return nil, err
        }
    }

    f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, opts.Perm)
    if err != nil {
        return nil, err
    }

    // Post-open verification closes the TOCTOU window
    if !opts.AllowSymlink {
        if err := verifyHandle(f, path); err != nil {
            f.Close()
            os.Remove(path)
            return nil, fmt.Errorf("TOCTOU detected: %w", err)
        }
    }
    return f, nil
}
Enter fullscreen mode Exit fullscreen mode

The os.O_EXCL flag is the first defense — it atomically fails if the file already exists. The verifyHandle is the second defense — it catches the case where the path was swapped after the pre-check but before the kernel completed the open() syscall.

3. Path Traversal — The Dangerous Input

The Attack

A user provides a relative path with .. components: --output ../../.ssh/authorized_keys. Or a JSON file contains a filename like ../../../etc/cron.d/backdoor. After filepath.Join(root, userPath), the result escapes the intended directory.

The Defense: JoinWithinRoot

// JoinWithinRoot joins a root directory and a relative path, then verifies
// the result doesn't escape the root. Rejects absolute paths and
// separator-prefixed relative paths.
func JoinWithinRoot(root, relPath string) (string, error) {
    if filepath.IsAbs(relPath) {
        return "", fmt.Errorf("absolute path not allowed: %q", relPath)
    }

    cleaned := filepath.Clean(relPath)

    // Reject separator-prefixed paths (Windows: "\dir" is drive-relative)
    if strings.HasPrefix(cleaned, string(filepath.Separator)) {
        return "", fmt.Errorf("separator-prefixed path not allowed: %q", relPath)
    }

    joined := filepath.Join(root, cleaned)
    absJoined, err := filepath.Abs(joined)
    if err != nil {
        return "", err
    }
    absRoot, err := filepath.Abs(root)
    if err != nil {
        return "", err
    }

    // Verify the joined path is within root
    if !strings.HasPrefix(absJoined, absRoot+string(filepath.Separator)) && absJoined != absRoot {
        return "", fmt.Errorf("path %q escapes root %q", relPath, root)
    }
    return absJoined, nil
}
Enter fullscreen mode Exit fullscreen mode

The separator-prefix check is critical for Windows: \dir is a drive-relative path that filepath.IsAbs returns false for, but filepath.Join("C:\root", "\dir") produces C:\dir — escaping the root.

Where to Apply It

Every place your code joins a user-controlled path with a root directory:

// Archive operations: user-controlled filenames from observation snapshots
dst, err := fsutil.JoinWithinRoot(archiveDir, entry.Name)
if err != nil {
    return fmt.Errorf("path containment check: %w", err)
}

// Delete operations: user-controlled paths from prune commands
target, err := fsutil.JoinWithinRoot(observationsDir, filename)
if err != nil {
    return fmt.Errorf("path containment check: %w", err)
}
Enter fullscreen mode Exit fullscreen mode

4. SafeMkdirAll — The Directory Creation Trap

The Attack

os.MkdirAll("/home/user/project/output/reports", 0o700) creates all intermediate directories. What if /home/user/project/output is a symlink to /etc? MkdirAll follows it and creates /etc/reports — a directory you never intended to create.

The Defense: Component-by-Component Creation

// SafeMkdirAll creates directories one component at a time, checking each
// for symlinks before creating the next. No TOCTOU window between check
// and create because each component is Lstat'd immediately before Mkdir.
func SafeMkdirAll(path string, opts WriteOptions) error {
    absPath, err := filepath.Abs(path)
    if err != nil {
        return err
    }

    // Split into components and create each one
    components := splitPath(absPath)
    current := string(filepath.Separator)

    for _, comp := range components {
        current = filepath.Join(current, comp)
        info, err := os.Lstat(current)

        if err == nil {
            // Path exists — verify it's a directory, not a symlink
            if info.Mode()&os.ModeSymlink != 0 && !opts.AllowSymlink {
                return fmt.Errorf("symlink at intermediate component: %s", current)
            }
            if !info.IsDir() {
                return fmt.Errorf("not a directory: %s", current)
            }
            continue
        }

        if !os.IsNotExist(err) {
            return fmt.Errorf("lstat %s: %w", current, err)
        }

        // Doesn't exist — create it
        if mkErr := os.Mkdir(current, opts.Perm); mkErr != nil && !os.IsExist(mkErr) {
            return fmt.Errorf("mkdir %s: %w", current, mkErr)
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The difference from os.MkdirAll: each component is Lstat'd right before creation. There's no window between "check if /home/user/project/output is safe" and "create /home/user/project/output/reports" — they happen in the same loop iteration.

5. Atomic Writes — Crash Safety

The Attack

Not an attack — a crash. Your tool writes a report file. The process is killed mid-write. The user now has a partial, corrupt report file that looks valid (the filename exists) but contains truncated JSON.

The Defense: Temp File + Sync + Rename

func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
    dir := filepath.Dir(path)

    tmp, err := os.CreateTemp(dir, ".stave-*.tmp")
    if err != nil {
        return fmt.Errorf("create temp: %w", err)
    }
    tmpPath := tmp.Name()

    // Cleanup on any error
    defer func() {
        if tmpPath != "" {
            os.Remove(tmpPath)
        }
    }()

    if _, err := tmp.Write(data); err != nil {
        tmp.Close()
        return fmt.Errorf("write: %w", err)
    }
    if err := tmp.Sync(); err != nil {
        tmp.Close()
        return fmt.Errorf("sync: %w", err)
    }
    if err := tmp.Close(); err != nil {
        return fmt.Errorf("close: %w", err)
    }

    if err := os.Rename(tmpPath, path); err != nil {
        return fmt.Errorf("rename: %w", err)
    }
    tmpPath = "" // prevent deferred cleanup
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The temp file is created in the same directory as the target so Rename is atomic (same-filesystem). Sync ensures data is flushed to disk before the rename. If the process crashes at any point:

  • During write: temp file exists, target doesn't — no corruption
  • During sync: temp file exists, target doesn't — no corruption
  • After rename: target is the complete file — success

6. File Permissions — The Silent Leak

The Attack

Your tool writes a report containing S3 bucket policies, IAM configurations, and security findings. The file is created with 0o644 (world-readable). Any user on the system can read it. In a shared server or CI environment, that's a data leak.

The Defense

const (
    // Sensitive files: only the owner can read/write
    permFile = 0o600

    // Directories: only the owner can list/traverse
    permDir = 0o700
)
Enter fullscreen mode Exit fullscreen mode

Every WriteFile, CreateTemp, MkdirAll, and OpenFile call uses restrictive permissions by default. The WriteOptions struct makes this explicit:

type WriteOptions struct {
    Perm         fs.FileMode  // default: 0o600
    Overwrite    bool
    AllowSymlink bool
}
Enter fullscreen mode Exit fullscreen mode

The gosec linter (G306) catches 0o644 in WriteFile calls. We fixed every instance across the codebase — generated docs, state files, log files, marker files.

7. Archive Operations — Where Everything Combines

The Attack

Archive operations (moving snapshot files to an archive directory) combine all the previous attacks. The attacker controls filenames in the observation directory. A filename like ../../.bashrc escapes the archive directory. A symlink in the archive path redirects writes. A concurrent modification swaps the source between validation and copy.

The Defense: Validate-All-Then-Execute

func ApplyArchive(input ArchiveInput) ([]ArchivedFile, error) {
    absArchive, _ := filepath.Abs(input.ArchiveDir)
    archivePrefix := absArchive + string(filepath.Separator)

    // Phase 1: Validate ALL destination paths before ANY moves
    for _, entry := range input.Entries {
        absDst, _ := filepath.Abs(filepath.Join(input.ArchiveDir, entry.Name))
        if absDst != absArchive && !strings.HasPrefix(absDst, archivePrefix) {
            return nil, fmt.Errorf("path escapes archive: %s", entry.Name)
        }
    }

    // Phase 2: Execute moves with TOCTOU-safe source verification
    for _, entry := range input.Entries {
        if err := safeMove(entry.Source, entry.Dest, input.Overwrite); err != nil {
            return nil, err
        }
    }
    return results, nil
}
Enter fullscreen mode Exit fullscreen mode

The two-phase approach prevents a subtle attack: if validation and execution are interleaved, a path that passes validation could be swapped before execution. By validating all paths first, we ensure the entire operation is consistent.

The safeMove function opens the source with openVerifiedSource (TOCTOU-safe Lstat+Open+Verify), then uses os.Link for atomic no-overwrite placement or crossDeviceMove (temp+rename) for cross-filesystem moves.

The Defensive Utility Belt

All seven patterns are collected in a single package (internal/platform/fsutil/io.go) with a consistent API:

Function Protects Against
CheckSymlinkSafety(path) Symlink following
verifyHandle(f, path) TOCTOU race
JoinWithinRoot(root, rel) Path traversal
SafeMkdirAll(path, opts) Intermediate symlinks
SafeCreateFile(path, opts) Overwrite + symlink + TOCTOU
WriteFileAtomic(path, data, perm) Partial writes
ReadFileLimited(path) File size bombs

Each function is independently testable with dedicated negative tests: symlinks in middle components, deep paths exceeding walk limits, separator-prefixed paths on Windows, handle verification after swap, and cross-device move with permission preservation.

When NOT to Defend

Not every file operation needs the full defensive stack:

// Reading a file you control (embedded in the binary): no defense needed
data, _ := embeddedFS.ReadFile("schema.json")

// Writing to a path the user explicitly chose: defend
err := fsutil.SafeWriteFile(userPath, data, fsutil.WriteOptions{Perm: 0o600})
Enter fullscreen mode Exit fullscreen mode

Apply defenses when:

  • The path comes from the user (CLI flag, config file, environment variable)
  • The filename comes from data (JSON fields, YAML keys, observation snapshots)
  • The operation creates directories (intermediate components can be symlinks)
  • The output contains sensitive data (security findings, credentials, policies)

Skip defenses when:

  • The path is hardcoded or embedded
  • The file is read-only and public (help text, version info)
  • Performance matters and the threat model doesn't include local attackers

These 7 filesystem security patterns were implemented in Stave, an offline configuration safety evaluator. The defensive utilities in internal/platform/fsutil/io.go handle symlink protection, path containment, TOCTOU closure, atomic writes, and permission enforcement for all user-controlled file operations.

Top comments (0)