Repository: xroche/httrack (2026-07-27T11:52:12Z)

Issue:
Reading an .ndx cache with full-length URL fields reads past the buffer
`binput()` returns `count + 1`, which assumes it consumed a separator. When it stops because it filled its `max` instead, that `+1` skips a byte that is not a separator and the caller's cursor drifts forward. In `PT_LoadCache__Old` the cursor ends up past the heap buffer holding the `.ndx`, and the next `binput` call reads out of bounds. The loop's `a < (use + ndxSize)` guard does not catch it, because the drift happens after the check.

Reproducer against master (5638910), with proxytrack built `-fsanitize=address`:

```sh
python3 - <<'PY'
with open("big.ndx", "w") as f:
    f.write("CACHE-1.4\n")
    f.write("Wed, 01 Jan 2025 00:00:00 GMT\n")
    f.write("h" * 1024 + "\n" + "f" * 1024 + "\n" + "0\n")
PY
: >big.dat
proxytrack --convert out.arc big.ndx
```

```
ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 1
    #0 binput src/proxy/store.c:263
    #1 PT_LoadCache__Old src/proxy/store.c:1586
```

Only the legacy `.ndx` reader is affected, and fields that long take a hand-made or corrupt index, since anything HTTrack wrote is shorter. It is a read, not a write.

Found while fixing the neighbouring write overflow in #744, which bounded the second `binput` by the room the first left. That fix does not address this one: the first version of test 92 tripped it, so the committed test appends a trailing entry to isolate the field bound.



Discussion:

Patch:
diff --git a/src/proxy/store.c b/src/proxy/store.c
index ff6ed610..18f6b477 100644
--- a/src/proxy/store.c
+++ b/src/proxy/store.c
@@ -259,7 +259,6 @@ static int binput(char *buff, char *s, int max) {
   int count = 0;
   int destCount = 0;
 
-  // Note: \0 will return 1
   while(destCount < max && buff[count] != '\0' && buff[count] != '\n') {
     if (buff[count] != '\r') {
       s[destCount++] = buff[count];
@@ -268,8 +267,9 @@ static int binput(char *buff, char *s, int max) {
   }
   s[destCount] = '\0';
 
-  // then return the supplemental jump offset
-  return count + 1;
+  /* only step over a real separator: past the terminating NUL leaves the
+     caller's buffer, past a truncated field's next byte silently eats it */
+  return buff[count] == '\n' ? count + 1 : count;
 }
 
 static time_t file_timestamp(const char *file) {
@@ -1382,11 +1382,10 @@ static int cache_brstr(char *adr, char *s, size_t s_size) {
   char buff[256 + 4];
 
   off = binput(adr, buff, 256);
-  /* binput stops at adr's terminating NUL; a value only follows a real line
-     terminator, so never step past end-of-buffer. */
-  if (adr[off - 1] == '\0') {
+  /* no length-prefixed value follows a field the terminating NUL stopped */
+  if (adr[off] == '\0') {
     s[0] = '\0';
-    return off - 1;
+    return off;
   }
   /* an empty/non-numeric field leaves i unset: treat as length 0 */
   if (sscanf(buff, "%d", &i) != 1 || i < 0 || i > 32768)


Test patch:
diff --git a/tests/117_local-proxytrack-ndx-cursor.test b/tests/117_local-proxytrack-ndx-cursor.test
new file mode 100644
index 00000000..73c0c053
--- /dev/null
+++ b/tests/117_local-proxytrack-ndx-cursor.test
@@ -0,0 +1,107 @@
+#!/bin/bash
+# Issue #793: binput() stepped its caller's cursor over a byte it never
+# consumed, so any .ndx whose walk reaches the buffer's terminating NUL drifts
+# past it. The sanitizer CI build turns that read into a heap-buffer-overflow.
+
+set -euo pipefail
+
+dir=$(mktemp -d)
+cleanup() { rm -rf "$dir"; }
+trap 'set +e; cleanup' EXIT
+trap 'cleanup' HUP INT QUIT PIPE TERM
+
+fail() {
+    echo "FAIL: $1" >&2
+    exit 1
+}
+
+pad() { printf "%${1}s" '' | tr ' ' "$2"; }
+
+hdr() { printf 'CACHE-1.4\nWed, 01 Jan 2025 00:00:00 GMT\n'; }
+
+# Number of cache keys proxytrack built from index $1.
+items() {
+    local out n
+    : >"${1%.ndx}.dat" # the reader only proceeds when the sibling .dat opens
+    out=$(proxytrack --convert "$dir/out.arc" "$1" 2>&1) ||
+        fail "proxytrack died on $(basename "$1")"
+    n=$(sed -n 's/.*(\([0-9][0-9]*\) items added).*/\1/p' <<<"$out")
+    if [ -z "$n" ]; then
+        grep -q '(no items added)' <<<"$out" ||
+            fail "proxytrack reported no item count for $(basename "$1")"
+        n=0
+    fi
+    echo "$n"
+}
+
+# The URL is read in two halves into one buffer, bounded at 1024 then at what
+# the first half left. Two URLs differing only on that first bound must stay
+# two distinct keys; eating the byte on it collapses them into one.
+for at in 1023 1024; do
+    {
+        hdr
+        printf '%sA\n\n10\n' "$(pad "$at" h)"
+        printf '%sB\n\n20\n' "$(pad "$at" h)"
+    } >"$dir/first-$at.ndx"
+done
+got=$(items "$dir/first-1023.ndx")
+test "$got" = 2 || fail "control: distinct URLs gave $got keys, expected 2"
+got=$(items "$dir/first-1024.ndx")
+test "$got" = 2 || fail "URLs differing on the 1024 bound gave $got keys, expected 2"
+
+# The header field is read through the same helper, bounded at 256. One byte
+# past that bound must parse like any other over-long header, not lose an entry.
+for at in 257 258; do
+    {
+        pad "$at" C
+        printf '\na/1\nx\n1\nb/2\ny\n2\nc/3\nz\n3\n'
+    } >"$dir/header-$at.ndx"
+done
+got=$(items "$dir/header-258.ndx")
+test "$got" = 3 || fail "header 2 past its bound gave $got keys, expected 3"
+got=$(items "$dir/header-257.ndx")
+test "$got" = 3 || fail "header 1 past its bound gave $got keys, expected 3"
+
+# Second half, bounded at 2048 - 10 - 1: it fills that bound with the index
+# ending right there, so the byte stepped over is the terminating NUL.
+{
+    hdr
+    printf '%s\n' "$(pad 10 h)"
+    pad 2037 f
+} >"$dir/second.ndx"
+got=$(items "$dir/second.ndx")
+test "$got" = 1 || fail "second half at its bound gave $got keys, expected 1"
+
+# Third field, bounded at 200, likewise ending the index.
+{
+    hdr
+    printf 'h/1\nx\n'
+    pad 200 9
+} >"$dir/position.ndx"
+got=$(items "$dir/position.ndx")
+test "$got" = 1 || fail "position field at its bound gave $got keys, expected 1"
+
+# Nothing has to fill a bound: a field of any length ending the index without a
+# newline stops on the same terminating NUL.
+{
+    printf '0\nSKIP\n'
+    pad 1023 h
+} >"$dir/short.ndx"
+got=$(items "$dir/short.ndx")
+test "$got" = 1 || fail "index ending mid-field gave $got keys, expected 1"
+
+# The reported reproducer: both halves full, and the walk reaches the last line.
+{
+    hdr
+    printf '%s\n%s\n0\n' "$(pad 1024 h)" "$(pad 1024 f)"
+} >"$dir/tail.ndx"
+got=$(items "$dir/tail.ndx")
+test "$got" = 1 || fail "index ending on a full-length entry gave $got keys, expected 1"
+
+# A NUL at offset 0 stops the header field with nothing behind it to inspect.
+# Not named nul.ndx: Windows resolves NUL.anything to the null device.
+printf '\000' >"$dir/zerobyte.ndx"
+got=$(items "$dir/zerobyte.ndx")
+test "$got" = 0 || fail "NUL-only index gave $got keys, expected 0"
+
+echo "OK: an .ndx field ending on its bound or on the buffer end keeps the walk in place"
diff --git a/tests/Makefile.am b/tests/Makefile.am
index 5271a7b9..1b4e40dd 100644
--- a/tests/Makefile.am
+++ b/tests/Makefile.am
@@ -230,6 +230,7 @@ TESTS = \
 	105_suite-timeout.test \
 	110_local-ftp-parallel.test \
 	111_local-ftp-update-rest.test \
-	114_local-update-304-leak.test
+	114_local-update-304-leak.test \
+	117_local-proxytrack-ndx-cursor.test
 
 CLEANFILES = check-network_sh.cache