fix(libxce): bug-1077 ctrlstatus crash from shared reply-union type confusion

ctx->reply.u is a single untagged union shared across opcodes (debugmsg /
get(dataset_t*) / jobid / clusterconf / ctrlstatus alias the same 8 bytes).
Commit 61e1391 added speculative free-guards
`if (ctx->reply.u.ctrlstatus) free_CtrlStatus_ptr(...)` (and the clusterconf
twin). A non-NULL slot does NOT imply a CtrlStatus*: a failover-aborted
reslist/joblist leaves ctx->reply.u.get == &getreply (a dead STACK pointer)
because xce_get_with_condition/xce_resval only cleared the slot on the success
path, not the xce_get_req<0 / cbuf_get<0 early returns. The next ctrlstatus
reply (worker thread) freed that foreign pointer as a CtrlStatus -> ASan
SEGV / stack-use-after-return in free_CtrlStatus (controller.capnp.c:11638).

Fix (root cause, both layers):
- Revert the unsafe free-guards in xce_handle_reply: MOVE_PTR overwrite only,
  never free/deref an untagged union slot by type.
- Clear ctx->reply.u.get = NULL on ALL exit paths of xce_get_with_condition
  and xce_resval via a single `goto done` cleanup (forget-proof; the bug was
  one forgotten failover path). Also drops the now-unused controller.capnp.h.
- Defense-in-depth: handle_get_reply drops a reply when the slot is NULL.

Regression: expose xce_handle_reply and add
test_handle_reply_ctrlstatus_does_not_free_foreign_slot, which poisons the
shared slot with a foreign heap value and proves the ctrlstatus reply handler
does not free it (RED reproduced the exact free_CtrlStatus_ptr double-free;
GREEN after fix). xcelib_test 244/0, controller_unit_test 166/0, all binaries
build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSxwb2Xt5F84ATgiAZnH8V
This commit is contained in:
Rongsong Shen 2026-06-24 09:44:43 +00:00
parent 61e1391cd0
commit 8da8fd03dd
7 changed files with 943 additions and 767 deletions

View file

@ -24,6 +24,7 @@
#include "config.h" /* XCE_MAXHOSTNAME_LEN */
#include "request.h"
#include "xce/types.h"
#include "xcectx.h" /* xce_ctx_t, xce_handle_reply (bug-1077 regression) */
/* generated Cap'n Proto codec for CtrlStatus / CtrlPeer */
#include "controller.capnp.h"
@ -331,10 +332,68 @@ static void test_ctrlstatus_primary_addr_unknown_or_missing(void)
printf("test_ctrlstatus_primary_addr_unknown_or_missing: PASS\n");
}
/* ── reply-union type-confusion regression (bug-1077) ───────────────────────
*
* ctx->reply.u is a single untagged union shared by every opcode (debugmsg /
* get / jobid / clusterconf / ctrlstatus all alias the same 8 bytes). A
* failover-aborted reslist/joblist leaves ctx->reply.u.get pointing at the
* caller's (now-dead) stack `getreply`. When the next ctrlstatus reply lands on
* a worker thread, handle_reply must NOT treat that foreign, non-NULL slot as a
* CtrlStatus* and free it — doing so dereferenced a dead stack address inside
* free_CtrlStatus and crashed (ASan stack-use-after-return at
* controller.capnp.c:11638).
*
* This drives xce_handle_reply directly with a poisoned union slot and proves
* the foreign occupant is left untouched (no free, no deref). Pre-fix, the
* ctrlstatus branch frees the sentinel and the explicit free() below is a
* double-free that ASan aborts on. */
static void test_handle_reply_ctrlstatus_does_not_free_foreign_slot(void)
{
xce_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
/* A prior get/reslist leaked a non-CtrlStatus pointer into the shared slot.
* Sized as a zeroed xce_ctrlstatus_t so that, even on the buggy path,
* free_CtrlStatus(sentinel) only walks all-NULL fields (no wild deref) and
* the regression surfaces as a clean double-free rather than a hard crash. */
xce_ctrlstatus_t* sentinel = (xce_ctrlstatus_t*)calloc(1, sizeof(*sentinel));
TEST_ASSERT_NOT_NULL(sentinel);
ctx.reply.u.get = (dataset_t*)sentinel; /* same 8 bytes as u.ctrlstatus */
/* A genuine ctrlstatus reply arrives for this ctx. */
xce_ctrlstatus_t* cs = (xce_ctrlstatus_t*)calloc(1, sizeof(*cs));
TEST_ASSERT_NOT_NULL(cs);
cs->clusterName = strdup("eda1");
xce_reply_t reply;
memset(&reply, 0, sizeof(reply));
reply.opcode = OPCode_ctrlstatus;
reply.status = 0;
reply.data_which = RequestReply_data_ctrlstatus;
reply.data.ctrlstatus = cs;
int rc = xce_handle_reply(&ctx, &reply);
/* The genuine reply is now owned by the ctx (MOVE_PTR nulls the source) … */
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_EQUAL_PTR(cs, ctx.reply.u.ctrlstatus);
TEST_ASSERT_NULL(reply.data.ctrlstatus);
/* … and the foreign sentinel was NOT freed by the reply handler. Freeing it
* here is the sole owner's free; on the buggy code it is the second free. */
free(sentinel);
free_CtrlStatus(cs);
free(cs);
printf("test_handle_reply_ctrlstatus_does_not_free_foreign_slot: PASS\n");
}
/* ── Suite entry point ──────────────────────────────────────────────────── */
void test_ctrlstatus(void)
{
RUN_TEST(test_handle_reply_ctrlstatus_does_not_free_foreign_slot);
RUN_TEST(test_ctrlstatus_roundtrip_all_fields);
RUN_TEST(test_ctrlstatus_roundtrip_unknown_primary);
RUN_TEST(test_xcecli_ctrlstatus_null_ctx_typed_failure);