189bb31737
#1 [HIGH, was a real DoS]: readNetstringStream read the length token via bufio.ReadString(':'), which buffers the whole pre-':' run UNBOUNDED — the 1<<24 cap only bounded the body, applied AFTER the token was parsed. A protocol-confined caller sending an endless digit run with no ':' drove sicd to ~1 GB RSS (measured by @reviewer). Now the token is read byte-by-byte with an 8-digit hard cap (a valid <=1<<24 length is 8 digits), so it rejects after <=9 bytes regardless of input size — memory is bounded by construction, before the value is trusted. #2 [MED]: nothing bounded the NUMBER of netstrings per frame (2M tiny ones ~140 MB before exec). runV1 now caps element count (4096) and total argv bytes (1<<20). #3 [MED, documented]: an empty netstring 0:, is always the terminator, so a legit empty "" argument collides with it (silent argv truncation + stdin bleed). Inherent to the v1 wire, not introduced here — noted in a comment; a fix would need a wire change. Regression tests: overlong length token (100k digits) -> exit 1; 9-digit length -> exit 1; frame over the element cap -> exit 1. go test ./... ok, go vet clean. Found by @reviewer's probe (reproduced 1 GB RSS), not a re-read — exactly why the outside look isn't skippable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
303 lines
9.1 KiB
Go
303 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
func readNetstring(buf []byte) ([]byte, bool) {
|
|
i := bytes.IndexByte(buf, ':')
|
|
if i < 0 {
|
|
return nil, false
|
|
}
|
|
// Reject non-digit characters (including leading '+' or '-')
|
|
for _, c := range buf[:i] {
|
|
if c < '0' || c > '9' {
|
|
return nil, false
|
|
}
|
|
}
|
|
n, err := strconv.Atoi(string(buf[:i]))
|
|
if err != nil || n < 0 {
|
|
return nil, false
|
|
}
|
|
// Sanity cap: reject lengths > 1<<24
|
|
if n > 1<<24 {
|
|
return nil, false
|
|
}
|
|
start := i + 1
|
|
end := start + n
|
|
if len(buf) < end+1 || buf[end] != ',' {
|
|
return nil, false
|
|
}
|
|
// Reject trailing data after the netstring's closing comma
|
|
if len(buf) > end+1 {
|
|
return nil, false
|
|
}
|
|
return buf[start:end], true
|
|
}
|
|
|
|
// readNetstringStream reads ONE djb netstring (<len>:<bytes>,) from a stream (unlike
|
|
// readNetstring, which parses a single fully-buffered frame). ok=false on EOF or malformed
|
|
// framing (non-digit length, short body, missing comma, length > 1<<24). Used by the v1 path,
|
|
// which is a SEQUENCE of netstrings terminated by the empty netstring 0:,.
|
|
func readNetstringStream(r *bufio.Reader) ([]byte, bool) {
|
|
// Read the length token digit-by-digit with a HARD cap. This must be bounded BEFORE the
|
|
// value is trusted: bufio.ReadString(':') buffers the whole pre-':' run unbounded, so an
|
|
// endless stream of digits with no ':' drives allocation to OOM (a confined caller measured
|
|
// ~1 GB RSS). A valid length is <= 1<<24 = 16777216 (8 digits), so >8 digits is malformed.
|
|
const maxLenDigits = 8
|
|
var lenBuf []byte
|
|
for {
|
|
b, err := r.ReadByte()
|
|
if err != nil {
|
|
return nil, false // EOF before ':' — truncated
|
|
}
|
|
if b == ':' {
|
|
break
|
|
}
|
|
if b < '0' || b > '9' {
|
|
return nil, false
|
|
}
|
|
lenBuf = append(lenBuf, b)
|
|
if len(lenBuf) > maxLenDigits {
|
|
return nil, false
|
|
}
|
|
}
|
|
if len(lenBuf) == 0 {
|
|
return nil, false
|
|
}
|
|
n, err := strconv.Atoi(string(lenBuf))
|
|
if err != nil || n > 1<<24 {
|
|
return nil, false
|
|
}
|
|
content := make([]byte, n)
|
|
if _, err := io.ReadFull(r, content); err != nil {
|
|
return nil, false
|
|
}
|
|
comma, err := r.ReadByte()
|
|
if err != nil || comma != ',' {
|
|
return nil, false
|
|
}
|
|
return content, true
|
|
}
|
|
|
|
func waitForChild(cmd *exec.Cmd) int {
|
|
if err := cmd.Wait(); err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
|
|
return 128 + int(ws.Signal())
|
|
}
|
|
return exitErr.ExitCode()
|
|
}
|
|
fmt.Fprintf(os.Stderr, "sicd: wait: %v\n", err)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func main() {
|
|
signal.Ignore(syscall.SIGPIPE)
|
|
|
|
// Dual-read dispatch on the FIRST byte, so a v2 daemon can be deployed without bricking the
|
|
// v1 clients still in the field: 0x00 = the v2 preamble; an ASCII digit = the first byte of
|
|
// a v1 netstring length (legacy, no preamble); anything else is malformed.
|
|
first := make([]byte, 1)
|
|
if _, err := io.ReadFull(os.Stdin, first); err != nil {
|
|
fmt.Fprintf(os.Stderr, "sicd: read first byte: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
switch {
|
|
case first[0] == 0x00:
|
|
runV2() // magic consumed; the rest (len32 + netstring + trailing) is on stdin
|
|
case first[0] >= '0' && first[0] <= '9':
|
|
// The byte we consumed for dispatch is the first digit of the first netstring's
|
|
// length — push it back before parsing.
|
|
runV1(bufio.NewReader(io.MultiReader(bytes.NewReader(first), os.Stdin)))
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "sicd: invalid first byte: 0x%02x\n", first[0])
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// runV2 is the v2 wire path (magic already consumed by main): 4-byte big-endian length, one
|
|
// netstring whose content is <command> 0x00 <payload>, then trailing stdin forwarded to the child.
|
|
func runV2() {
|
|
lenBytes := make([]byte, 4)
|
|
if _, err := io.ReadFull(os.Stdin, lenBytes); err != nil {
|
|
fmt.Fprintf(os.Stderr, "sicd: read length: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
nsLen := binary.BigEndian.Uint32(lenBytes)
|
|
|
|
nsBuf := make([]byte, nsLen)
|
|
if _, err := io.ReadFull(os.Stdin, nsBuf); err != nil {
|
|
fmt.Fprintf(os.Stderr, "sicd: read netstring: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
content, ok := readNetstring(nsBuf)
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "sicd: invalid netstring\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
nulIdx := bytes.IndexByte(content, 0x00)
|
|
if nulIdx < 0 {
|
|
fmt.Fprintf(os.Stderr, "sicd: content missing NUL separator\n")
|
|
os.Exit(1)
|
|
}
|
|
command := content[:nulIdx]
|
|
payload := content[nulIdx+1:]
|
|
|
|
argv := bytes.Split(command, []byte(" "))
|
|
argvStr := make([]string, len(argv))
|
|
for i, a := range argv {
|
|
argvStr[i] = string(a)
|
|
}
|
|
if len(argvStr) == 0 {
|
|
fmt.Fprintf(os.Stderr, "sicd: empty command\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
cmd := exec.Command(argvStr[0], argvStr[1:]...)
|
|
stdinPipe, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "sicd: create stdin pipe: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "sicd: exec %s: %v\n", argvStr[0], err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := writeAll(stdinPipe, payload); err != nil {
|
|
if errors.Is(err, syscall.EPIPE) {
|
|
// Child died before reading all payload.
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "sicd: write payload: %v\n", err)
|
|
}
|
|
stdinPipe.Close()
|
|
os.Exit(waitForChild(cmd))
|
|
}
|
|
|
|
os.Exit(pumpStdin(stdinPipe, os.Stdin, func() int { return waitForChild(cmd) }, os.Stderr))
|
|
}
|
|
|
|
// runV1 handles the legacy v1 wire the deployed cmd/sic still sends (raw netstrings, no
|
|
// preamble): first netstring = mode ("exec" | "sh"); then the argv/command netstrings;
|
|
// terminated by the empty netstring 0:,. Everything after the terminator is the child's stdin.
|
|
// Kept only for the migration window so a v2 daemon does not reject v1 callers.
|
|
func runV1(r *bufio.Reader) {
|
|
modeB, ok := readNetstringStream(r)
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "sicd: v1 malformed frame (mode)\n")
|
|
os.Exit(1)
|
|
}
|
|
mode := string(modeB)
|
|
if mode != "exec" && mode != "sh" {
|
|
fmt.Fprintf(os.Stderr, "sicd: v1 unknown mode %q\n", mode)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Read argv/command netstrings until the empty-netstring terminator. EOF first = truncated.
|
|
// Cap the frame (element count AND total bytes): readNetstringStream bounds each netstring at
|
|
// 1<<24, but nothing bounds their NUMBER — 2M tiny netstrings measured ~140 MB before exec.
|
|
// NOTE: an empty netstring 0:, is ALWAYS the terminator, so a legitimate empty "" argument
|
|
// collides with it — the arg list truncates there and the rest bleeds into the child's stdin.
|
|
// That is inherent to the v1 wire (terminator overload), not a bug introduced here; the
|
|
// deployed cmd/sic produces it only if a caller passes a literal "".
|
|
const maxArgs = 4096
|
|
const maxFrameBytes = 1 << 20
|
|
var argv []string
|
|
total := 0
|
|
for {
|
|
ns, ok := readNetstringStream(r)
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "sicd: v1 truncated frame (no 0:, terminator)\n")
|
|
os.Exit(1)
|
|
}
|
|
if len(ns) == 0 {
|
|
break // the empty netstring 0:, terminates the frame
|
|
}
|
|
total += len(ns)
|
|
if len(argv) >= maxArgs || total > maxFrameBytes {
|
|
fmt.Fprintf(os.Stderr, "sicd: v1 frame too large\n")
|
|
os.Exit(1)
|
|
}
|
|
argv = append(argv, string(ns))
|
|
}
|
|
|
|
var cmd *exec.Cmd
|
|
switch mode {
|
|
case "exec":
|
|
if len(argv) == 0 {
|
|
fmt.Fprintf(os.Stderr, "sicd: v1 empty exec argv\n")
|
|
os.Exit(1)
|
|
}
|
|
cmd = exec.Command(argv[0], argv[1:]...) // each netstring is ONE argv element, never space-split
|
|
case "sh":
|
|
cmd = exec.Command("sh", "-c", strings.Join(argv, " "))
|
|
}
|
|
|
|
stdinPipe, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "sicd: create stdin pipe: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "sicd: exec: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// v1 has no framed payload: everything after 0:, (still buffered in r + the rest of stdin)
|
|
// is the child's stdin. Reuse the v2 pump so EPIPE/reap/exit-status semantics match exactly.
|
|
os.Exit(pumpStdin(stdinPipe, r, func() int { return waitForChild(cmd) }, os.Stderr))
|
|
}
|
|
|
|
// pumpStdin copies src (sicd's own stdin) into the child's stdin (dst), closes dst so the
|
|
// child sees EOF, then reaps. EPIPE from the WRITE side means the child stopped reading
|
|
// (| head semantics) — not an error, so the child's own status is returned. Any OTHER error
|
|
// (a genuine read/write failure) means the transfer was truncated: report a diagnostic, reap
|
|
// to avoid a zombie, and return non-zero REGARDLESS of the child's status — a truncated
|
|
// transfer must never be reported as success. Extracted so a mock erroring io.Reader can
|
|
// cover this branch: that is exactly how the Python reference tests it (monkeypatching
|
|
// os.read to raise OSError), since no real fd on this platform yields a read error on stdin
|
|
// (a pty slave and a socket peer both see a clean kernel EOF on hangup, in Go AND CPython).
|
|
func pumpStdin(dst io.WriteCloser, src io.Reader, reap func() int, errOut io.Writer) int {
|
|
_, err := io.Copy(dst, src)
|
|
dst.Close()
|
|
if err != nil && !errors.Is(err, syscall.EPIPE) {
|
|
fmt.Fprintf(errOut, "sicd: forward stdin: %v\n", err)
|
|
reap()
|
|
return 1
|
|
}
|
|
return reap()
|
|
}
|
|
|
|
func writeAll(w io.Writer, data []byte) error {
|
|
for off := 0; off < len(data); {
|
|
n, err := w.Write(data[off:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
off += n
|
|
}
|
|
return nil
|
|
}
|