From 58a3328422c3c9553249eaa50b07df0ed446c987 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Thu, 26 Mar 2026 13:44:42 -0400 Subject: [PATCH] Fix GC use-after-free of multipart form data during perform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- lib/ethon/easy.rb | 1 + lib/ethon/easy/http.rb | 3 ++- spec/ethon/easy/http_spec.rb | 43 ++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/ethon/easy.rb b/lib/ethon/easy.rb index 355a5194..b497b87d 100644 --- a/lib/ethon/easy.rb +++ b/lib/ethon/easy.rb @@ -256,6 +256,7 @@ def reset @on_progress = nil @procs = nil @mirror = nil + @active_action = nil Curl.easy_reset(handle) set_callbacks end diff --git a/lib/ethon/easy/http.rb b/lib/ethon/easy/http.rb index 43598305..22133349 100644 --- a/lib/ethon/easy/http.rb +++ b/lib/ethon/easy/http.rb @@ -37,7 +37,8 @@ module Http # # @see Ethon::Easy::Options def http_request(url, action_name, options = {}) - fabricate(url, action_name, options).setup(self) + @active_action = fabricate(url, action_name, options) + @active_action.setup(self) end private diff --git a/spec/ethon/easy/http_spec.rb b/spec/ethon/easy/http_spec.rb index a7d985fe..a6dbb610 100644 --- a/spec/ethon/easy/http_spec.rb +++ b/spec/ethon/easy/http_spec.rb @@ -60,5 +60,48 @@ expect(easy.response_body).to include(%{"REQUEST_METHOD":"PURGE"}) end end + + context "when posting multipart form data" do + it "retains a reference to the action to prevent GC of form data" do + easy.http_request(url, :post, body: { text: "value" }) + expect(easy.instance_variable_get(:@active_action)).to_not be_nil + end + + it "clears the action reference on reset" do + easy.http_request(url, :post, body: { text: "value" }) + easy.reset + expect(easy.instance_variable_get(:@active_action)).to be_nil + end + + it "does not segfault when GC runs during multipart perform" do + # Reproducer for GC use-after-free: without the fix, the Form + # object (and its FFI::AutoPointer to curl_formfree) becomes + # unreachable after http_request returns. Since easy_perform + # releases the GVL (@blocking=true), GC can finalize the Form + # mid-request, freeing the curl_httppost chain that libcurl is + # reading → SIGSEGV. + # + # We force GC between http_request and perform to maximize the + # chance of triggering the bug. With the fix, @active_action + # prevents collection. + tmpfile = Tempfile.new(["ethon_test", ".bin"]) + tmpfile.write("x" * 50_000) + tmpfile.rewind + + 10.times do + e = Ethon::Easy.new + e.http_request(url, :post, { + body: { file: [File.basename(tmpfile.path), "application/octet-stream", tmpfile.path] }, + multipart: true + }) + GC.start(full_mark: true, immediate_sweep: true) + e.perform + expect(e.return_code).to eq(:ok) + end + + tmpfile.close + tmpfile.unlink + end + end end end