Skip to content

mod_xml2enc: Fix a use-after-free of carried-over input, and don't abort when the response body fails part way through - #835

Open
alepacheco wants to merge 1 commit into
apache:trunkfrom
alepacheco:xml2enc-error-bucket
Open

alepacheco wants to merge 1 commit into
apache:trunkfrom
alepacheco:xml2enc-error-bucket

Conversation

@alepacheco

@alepacheco alepacheco commented Oct 12, 2026 •

Copy link
Copy Markdown

This is four fixes in xml2enc_ffunc(), found while fuzzing the filter chain. The first is a use-after-free; the other three are child-process aborts. All four are triggered by the backend or CGI script failing, which SECURITY.md treats as trusted, so this is a public PR.

Use-after-free of carried-over input

When mod_xml2enc converts a charset libxml2 doesn't handle itself (via apr_xlate, e.g. Big5, Shift_JIS, GB18030), and a whole input buffer converts to nothing — every byte is the first half of a multibyte character — it reinserts the unconsumed bytes at the head of the brigade:

b = apr_bucket_transient_create(buf+(bytes - insz), insz, bb->bucket_alloc);
APR_BRIGADE_INSERT_HEAD(bb, b);

buf is the data of bdestroy, the bucket read earlier this iteration, which is destroyed at the bottom of the loop (apr_bucket_destroy(bdestroy)). The transient bucket still points into it, and the next iteration's apr_brigade_flatten() reads the freed region:

==ERROR: AddressSanitizer: heap-use-after-free ... in __asan_memcpy
    #1 apr_brigade_flatten apr_brigade.c:253
    #2 xml2enc_ffunc mod_xml2enc.c:461
freed by:
    #3 heap_bucket_destroy
    #4 xml2enc_ffunc mod_xml2enc.c:573   (apr_bucket_destroy(bdestroy))
allocated by:
    #4 cgi_read_stdout                   (the bucket whose data buf points into)

It needs the handler to return the body in several reads that each end mid-character — a CGI script that flushes 8000-byte Big5 chunks does it deterministically. It is normally masked because the freed block is reused, not released; it shows under ASan, --enable-pool-debug, or MaxMemFree 1. Fix: copy into a heap bucket (apr_bucket_heap_create(..., NULL, ...)) so the reinserted data owns itself. This also covers the case where the input came from the flattened fixbuf: there the transient pointed into a block-scoped stack array, which the next iteration's flatten then wrote into.

Three aborts when the body fails part way through

xml2enc_ffunc() has four ap_assert()s on I/O results. Each aborts the child (AH00102 ... assertion "rv == APR_SUCCESS" failed) when the body fails mid-stream:

  • error bucket while sniffing (line 381): since r1893595 mod_proxy_http/ajp/uwsgi report a mid-body backend failure with an error bucket and no EOS. While still collecting <4 KB to sniff, xml2enc waits for more unless it sees EOS and sets the brigade aside; apr_bucket_setaside() is not implemented for error buckets. Reproducer: a backend sending Content-Length: 1000 and a short body, then closing.
  • error bucket after a partial sequence (line 476): the dangling-data set-aside path hits the same unimplemented setaside. If an EOS follows the partial sequence instead, it is held back with the sequence and the response never ends.
  • failed read while sniffing / after a partial sequence (lines 394, 462): with ProxyHTMLEnable On over CGI, a script that writes part of a page then stalls past CGIScriptTimeout makes the CGI bucket's blocking read return APR_TIMEUP, so apr_brigade_flatten() fails.

The change:

  • treat an error bucket like EOS when deciding whether to wait for more data to sniff;
  • when the data ends (EOS or error bucket) after a partial multibyte sequence, drop the sequence, log it (empty APLOGNO()), and pass the rest on;
  • if the flatten for sniffing fails, log it, remove the filter and pass the saved brigade on as is (like the existing no-conversion path);
  • if the flatten of a partial sequence fails, log and return the error, as the existing apr_bucket_read() failure path (AH01447) does.

Tests

test/modules/filters/test_004_xml2enc.py (self-contained fake backend + CGI scripts; MaxMemFree 1 so the UAF is caught, skips where apr_xlate lacks Big5). On trunk: a child abort at each of the four asserts, one truncated response, and the use-after-free. All pass with the change. test/modules/filters and test/modules/proxy pass.

2.4.x has the same code for all four. There ap_proxy_backend_broke() adds an EOS after the error bucket, so the proxy can't reach the first two asserts, but the CGI-timeout ones and the use-after-free look reachable the same way.


  • This PR does not report or address a security vulnerability
    (see SECURITY.md and
    https://www.apache.org/security/#reporting-a-vulnerability).
  • Code follows the httpd style guide.
  • New log messages (level debug or higher) use an empty APLOGNO() tag;
    numbers are assigned by a committer at merge time (see docs/log-message-tags/README).
  • If the change is user-visible, a changes-entries/*.txt file is included,
    following the template in README.CHANGES (not needed otherwise).
  • Test cases based on pyhttpd are included where appropriate, in the
    test/modules/xxx directory matching the modules/xxx module source,
    or test/modules/core for core server changes. On Unix, verify these
    pass locally with e.g. make check-pytest PYTEST_DIRS=test/modules/xxx.

@alepacheco
alepacheco force-pushed the xml2enc-error-bucket branch from 4c03985 to 900276f Compare October 12, 2026 22:55
@alepacheco alepacheco changed the title mod_xml2enc: Don't abort on error buckets, and pass on the end of the data after a partial multibyte sequence mod_xml2enc: Don't abort when the response body fails part way through Oct 12, 2026
@alepacheco
alepacheco force-pushed the xml2enc-error-bucket branch from 900276f to 13641e0 Compare October 12, 2026 23:02
abort when the response body fails part way through.

* modules/filters/mod_xml2enc.c (xml2enc_ffunc): When nothing of the
  input could be converted, copy the unconsumed bytes into a heap bucket
  rather than referencing them with a transient bucket: they point into
  the data of the bucket just read, which is destroyed before the loop
  goes round again, so the next apr_brigade_flatten() read freed memory.
  This happens when every read from the handler (e.g. a CGI script)
  ends in a partial multibyte sequence.
  Stop waiting for more data to sniff the charset once an error bucket
  arrives, as for EOS, instead of trying to set it aside: setaside isn't
  implemented for error buckets, so the ap_assert() aborted the child.
  mod_proxy_http, mod_proxy_ajp and mod_proxy_uwsgi send an error
  bucket, but no EOS, when the backend fails part way through the body.
  When the data ends (EOS or error bucket) after a partial multibyte
  sequence, drop the sequence and pass on what follows rather than
  setting everything aside, which aborted on an error bucket in the
  same way, and held back the EOS so the response never ended.
  If reading the data to sniff fails (e.g. a CGI script times out),
  remove the filter and pass the data on as is, and if reading the rest
  of a partial multibyte sequence fails, log and return the error, as
  for a failed bucket read, instead of the ap_assert()s aborting the
  child.

* test/modules/filters/test_004_xml2enc.py,
  test/modules/filters/htdocs/test1/cgi/stall.py,
  test/modules/filters/htdocs/test1/cgi/stall-big5.py,
  test/modules/filters/htdocs/test1/cgi/split-big5.py: Add tests.

* test/modules/filters/env.py: Load mod_proxy, mod_proxy_http,
  mod_xml2enc and mod_proxy_html if available.
@alepacheco
alepacheco force-pushed the xml2enc-error-bucket branch from 13641e0 to 1501e6d Compare October 12, 2026 23:30
@alepacheco alepacheco changed the title mod_xml2enc: Don't abort when the response body fails part way through mod_xml2enc: Fix a use-after-free of carried-over input, and don't abort when the response body fails part way through Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant