Skip to content

Fix GC use-after-free of multipart form data during perform#272

Open
faoiseamh wants to merge 1 commit intotyphoeus:masterfrom
faoiseamh:fix/multipart-form-gc-use-after-free
Open

Fix GC use-after-free of multipart form data during perform#272
faoiseamh wants to merge 1 commit intotyphoeus:masterfrom
faoiseamh:fix/multipart-form-gc-use-after-free

Conversation

@faoiseamh
Copy link
Copy Markdown

Summary

http_request creates a Post action containing a Form object, calls .setup(self) to set CURLOPT_HTTPPOST on the curl handle, then discards the action — no Ruby reference is stored on the Easy instance. The Form holds an FFI::AutoPointer that calls curl_formfree() when garbage collected.

Curl.easy_perform is declared with @blocking = true, so FFI releases the GVL during the call. While libcurl is sending multipart data, Ruby's GC can finalize the now-unreachable Form, calling curl_formfree() and freeing the curl_httppost chain that libcurl is actively reading → SIGSEGV.

Why this is hard to reproduce

  • Only affects multipart form uploads (requests using curl_formadd/CURLOPT_HTTPPOST). Regular POST with string bodies use CURLOPT_POSTFIELDS, which libcurl copies internally.
  • Non-deterministic — depends on GC timing. More likely under memory pressure, repeated uploads, or with TLS (longer curl_easy_perform = wider GC window).
  • Crash addresses vary: we observed 0x0000000019751858 (stale heap pointer after realloc) and 0x0000000000000045 (NULL + struct field offset after free) — both during easy_perform on multipart POSTs.

The fix

lib/ethon/easy/http.rb — Store the action as @active_action on the Easy instance:

def http_request(url, action_name, options = {})
  @active_action = fabricate(url, action_name, options)
  @active_action.setup(self)
end

lib/ethon/easy.rb — Clear the reference in reset so pooled handles don't hold stale form data:

@active_action = nil

How we found this

We run a Rails app on RHEL9 (libcurl 7.76.1) that uploads medical images via multipart POST through Typhoeus/Ethon. After upgrading to ethon 0.18.0, sequential multipart uploads would SIGSEGV after several requests. The crash always occurred inside curl_easy_perform after a successful TLS handshake, only on multipart POSTs, and only when enough prior requests had run to trigger GC pressure.

The C-level backtrace confirmed the crash originated inside libcurl's form data reading code, and rb_nogvl in the call stack confirmed the GVL was released. Forcing GC.start between http_request and perform reliably triggers the bug without the fix.

Test plan

  • New spec: retains a reference to the action to prevent GC of form data
  • New spec: clears the action reference on reset
  • New spec: does not segfault when GC runs during multipart perform (forces GC.start between http_request and perform in a loop with 50KB file uploads)
  • All existing specs pass (30 examples, 0 failures)

`http_request` creates a Post action containing a Form object, calls
`.setup(self)` to set CURLOPT_HTTPPOST on the curl handle, then discards
the action — no Ruby reference is kept. The Form holds an FFI::AutoPointer
that calls `curl_formfree()` when garbage collected.

`Curl.easy_perform` is declared with `@blocking = true`, so FFI releases
the GVL during the call. While libcurl is sending multipart data, Ruby's
GC can finalize the now-unreachable Form, calling `curl_formfree()` and
freeing the `curl_httppost` chain that libcurl is actively reading. This
causes a segmentation fault (SIGSEGV).

The bug is non-deterministic — it depends on GC timing and is more likely
under memory pressure or with repeated multipart uploads. It only affects
multipart form uploads (requests using `curl_formadd`/`CURLOPT_HTTPPOST`);
regular POST requests using `CURLOPT_POSTFIELDS` are unaffected because
libcurl copies that data internally.

Fix: store the action as `@active_action` on the Easy instance so the
Form (and its native `curl_httppost` data) remains reachable during
`perform`. Clear the reference in `reset` so pooled handles don't hold
stale form data.
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