#1308·memcached

[Security] Proxy `rctx:request_new()` placeholder request can trigger `raw_line()` length underflow, causing heap OOB read and server crash

Author: gff-cwCreated Aug 1, 2026Updated Aug 1, 2026

Summary

rctx:request_new() in memcached proxy mode creates a placeholder mcp.request object by calling:

c
mcp_new_request(L, &pr, " ", 1);

This produces an mcp.request object whose internal request buffer contains only one byte (' ') and whose parser state has:

rq->pr.reqlen      == 1
rq->pr.tok.ntokens == 0
rq->pr.keytoken    == 0

However, the same object is exposed to Lua as a normal mcp.request object and therefore has access to the full mcp.request method table, including raw_line().

mcplib_request_raw_line() assumes that the request is a complete CRLF-terminated protocol line and unconditionally subtracts two bytes from the unsigned reqlen field:

c
lua_pushlstring(L, rq->pr.request, rq->pr.reqlen-2);

When rq->pr.reqlen == 1, the expression rq->pr.reqlen - 2 underflows in the uint32_t domain to 0xffffffff (4294967295). This wrapped length is then passed to Lua as the string length and reaches memcpy() inside Lua's string allocator, causing a huge heap out-of-bounds read and a reliable server crash.

Impact confirmed locally:

  • ASan/UBSan build: AddressSanitizer: heap-buffer-overflow
  • ASan read size: 4294967295
  • Crash site: mcplib_request_raw_line() -> lua_pushlstring() -> luaS_newlstr() -> memcpy()
  • Release build: process exits with SIGSEGV / exit code 139

Version

Affected version tested: 1.6.45 Commit: 2d51e36


Security impact

Process-level denial of service in the official memcached server when the built-in proxy is enabled and a vulnerable Lua route is installed.

A remote client request can enter the proxy request path, cause the configured Lua route to execute, and make the worker call raw_line() on a placeholder request object created by rctx:request_new(). In the tested release build, the server process reliably crashed with SIGSEGV.


Affected path

Observed runtime path:

client socket request
  -> try_read_command_proxy()
  -> proxy_process_command()
  -> proxy_run_rcontext()
  -> lua_resume()
  -> Lua route
  -> rctx:request_new()
  -> mcplib_rcontext_request_new()
  -> mcp_new_request(L, &pr, " ", 1)
  -> nreq:raw_line()
  -> mcplib_request_raw_line()
  -> lua_pushlstring(L, rq->pr.request, rq->pr.reqlen - 2)
  -> luaS_newlstr()
  -> memcpy()
  -> heap out-of-bounds read / SIGSEGV

Root cause analysis

1. mcp.request stores parser state plus a flexible request buffer

In proxy.h, mcp_request_t is a small wrapper around mcp_parser_t followed by a flexible array buffer:

c
typedef struct mcp_request_s mcp_request_t;

struct mcp_request_s {
    mcp_parser_t pr; // non-lua-specific parser handling.
    char request[];
};

There is no separate type-level distinction between:

  • a fully parsed canonical request object, and
  • a placeholder request object intended to be filled later.

Therefore, once a placeholder is returned as mcp.request, normal request methods can consume it.

2. mcp_parser_t.reqlen is unsigned 32-bit

In proto_parser_type.h, reqlen is defined as uint32_t:

c
struct mcp_parser_s {
    const char *request;
    void *vbuf;
    mcmc_tokenizer_t tok;
    uint8_t command;
    uint8_t cmd_type;
    uint8_t keytoken;
    uint32_t reqlen; // full length of request buffer.
    int vlen;
    uint32_t klen;
    bool noreply;
};

This matters because the subtraction in raw_line() is performed on an unsigned integer field.

3. rctx:request_new() creates a placeholder request with cmdlen == 1

In proxy_luafgen.c, mcplib_rcontext_request_new() creates a zero-initialized parser state and then calls mcp_new_request() with a single-space string and length 1:

c
int mcplib_rcontext_request_new(lua_State *L) {
    mcp_rcontext_t *rctx = lua_touserdata(L, 1);
    if (rctx->uobj_count == rctx->fgen->uobj_queues) {
        proxy_lua_error(L, "rctx request new: object count limit reached");
        return 0;
    }

    // create new request object
    mcp_parser_t pr = {0};
    mcp_request_t *rq = mcp_new_request(L, &pr, " ", 1);
    _mcplib_rcontext_ref_uobj(L, rctx, rq, RQUEUE_TYPE_UOBJ_REQ);
    return 1;
}

This is the first broken invariant: the returned object has the type and method table of a normal mcp.request, but its buffer is not a complete memcached request line and is not CRLF-terminated.

4. mcp_new_request() copies cmdlen directly into rq->pr.reqlen

In proxy_request.c, mcp_new_request() copies the supplied parser state, copies the supplied command buffer, points pr.request at the embedded flexible array, and stores cmdlen directly as reqlen:

c
mcp_request_t *mcp_new_request(lua_State *L, mcp_parser_t *pr, const char *command, size_t cmdlen) {
    mcp_request_t *rq = lua_newuserdatauv(L, sizeof(mcp_request_t) + MCP_REQUEST_MAXLEN, 0);

    memset(rq, 0, sizeof(mcp_request_t));
    memcpy(&rq->pr, pr, sizeof(*pr));
    memcpy(rq->request, command, cmdlen);
    rq->pr.request = rq->request;
    rq->pr.reqlen = cmdlen;

    luaL_getmetatable(L, "mcp.request");
    lua_setmetatable(L, -2);

    return rq;
}

For the request_new() path:

command = " "
cmdlen  = 1
rq->pr.reqlen = 1
rq->request[0] = 0x20

No check enforces that this object is parse-complete or CRLF-terminated.

5. Normal mcp.request() construction does parse and validate request syntax

The normal Lua-exposed mcp.request() constructor in proxy_request.c follows a different contract. It expects a valid CRLF-terminated request string and parses it:

c
const char *cmd = luaL_checklstring(L, 1, &len);

if (len > MCP_REQUEST_MAXLEN) {
    proxy_lua_error(L, "request length too long");
    return 0;
}

if (memcmp(cmd+len-2, "\r\n", 2) != 0) {
    proxy_lua_error(L, "request must end with \r\n");
    return 0;
}

if (process_request(&pr, cmd, len) != 0) {
    proxy_lua_error(L, "failed to parse request");
    return 0;
}

mcp_request_t *rq = mcp_new_request(L, &pr, cmd, len);

This shows the intended invariant for a normal mcp.request object:

  • request buffer is a complete protocol line
  • request buffer ends with "\r\n"
  • parser state is populated by process_request()
  • tokenizer fields are meaningful
  • reqlen includes the complete request length

rctx:request_new() bypasses this canonical construction path but still returns the same type.

6. raw_line() assumes CRLF and subtracts 2 without validation

In proxy_request.c, mcplib_request_raw_line() is implemented as:

c
int mcplib_request_raw_line(lua_State *L) {
    mcp_request_t *rq = luaL_checkudata(L, 1, "mcp.request");
    lua_pushlstring(L, rq->pr.request, rq->pr.reqlen-2);
    return 1;
}

The implicit assumption is:

bash
rq->pr.reqlen >= 2
rq->pr.request[rq->pr.reqlen - 2] == '\r'
rq->pr.request[rq->pr.reqlen - 1] == '\n'

Those assumptions are true for a canonical request object, but false for the placeholder created by request_new().

For the placeholder:

rq->pr.reqlen = 1
rq->pr.reqlen - 2 = 0xffffffff

Because reqlen is uint32_t, the subtraction wraps in the 32-bit unsigned domain before being passed to lua_pushlstring().

7. The wrapped 32-bit value is widened to Lua's size_t length argument

The length passed to lua_pushlstring() is a size_t. Since the arithmetic result is already 0xffffffff, the value passed on a 64-bit host is:

(size_t)(uint32_t)0xffffffff = 4294967295

This is why the ASan trace reports:

READ of size 4294967295

and not:

READ of size 18446744073709551615

The underflow occurs at the uint32_t expression level, not as a signed -1 converted directly into a 64-bit size_t.

8. The proxy Lua method table makes the bad state reachable

In proxy_lua.c, raw_line is registered as an mcp.request method:

c
const struct luaL_Reg mcplib_request_m[] = {
    {"command", mcplib_request_command},
    {"key", mcplib_request_key},
    {"ltrimkey", mcplib_request_ltrimkey},
    {"rtrimkey", mcplib_request_rtrimkey},
    {"raw_line", mcplib_request_raw_line},
    {"raw_value", mcplib_request_raw_value},
    {"token", mcplib_request_token},
    ...
};

The same file registers request_new as an mcp.rcontext method:

c
const struct luaL_Reg mcplib_rcontext_m[] = {
    {"handle_set_cb", mcplib_rcontext_handle_set_cb},
    {"enqueue", mcplib_rcontext_enqueue},
    ...
    {"request_new", mcplib_rcontext_request_new},
    {"response_new", mcplib_rcontext_response_new},
    {"sleep", mcplib_rcontext_sleep},
    {NULL, NULL}
};

And the metatable setup makes these methods available through __index:

c
luaL_newmetatable(L, "mcp.request");
lua_pushvalue(L, -1);
lua_setfield(L, -2, "__index");
luaL_setfuncs(L, mcplib_request_m, 0);

luaL_newmetatable(L, "mcp.rcontext");
lua_pushvalue(L, -1);
lua_setfield(L, -2, "__index");
luaL_setfuncs(L, mcplib_rcontext_m, 0);

Therefore, Lua code can naturally reach the vulnerable combination:

lua
local nreq = rctx:request_new()
local line = nreq:raw_line()

9. Similar CRLF invariant assumptions exist elsewhere

mcp_request_append() also assumes that pr->request + pr->reqlen - 2 points at '\r':

c
int mcp_request_append(mcp_request_t *rq, const char flag, const char *tok, size_t len) {
    mcp_parser_t *pr = &rq->pr;
    const char *start = pr->request;
    char *p = (char *)pr->request + pr->reqlen - 2; // start at the \r
    assert(*p == '\r');
    ...
}

This is additional evidence that many request APIs assume canonical, CRLF-terminated request objects. The issue is that request_new() returns a non-canonical placeholder through the same public type.


GDB evidence

Two breakpoints were used:

gdb
break mcp_new_request if cmdlen==1
break mcplib_request_raw_line

Breakpoint 1: mcp_new_request(cmdlen == 1)

Observed values:

=== BP1 mcp_new_request(cmdlen==1) ===
command string: " "
$1 = 1

After stepping past the rq->pr.reqlen = cmdlen assignment:

gdb
print rq

$2 = (mcp_request_t *) 0x61600000ffa0
gdb
print rq->pr.reqlen

$3 = 1
gdb
print rq->pr.tok.ntokens

$4 = 0 '\000'
gdb
x/8bx rq->request

0x616000010008: 0x20 0xbe 0xbe 0xbe 0xbe 0xbe 0xbe 0xbe

Interpretation:

rq->pr.reqlen      = 1
rq->pr.tok.ntokens = 0
rq->request[0]     = 0x20 (' ')

This is not a valid canonical request line. It is a placeholder object.

Breakpoint 2: mcplib_request_raw_line

At raw_line():

lua_pushlstring(L, rq->pr.request, rq->pr.reqlen-2);

Observed values:

gdb
print rq

$6 = (mcp_request_t *) 0x61600000ffa0
gdb
print rq->pr.reqlen

$7 = 1
gdb
print rq->pr.tok.ntokens

$8 = 0 '\000'
gdb
print rq->pr.keytoken

$9 = 0 '\000'
gdb
print (unsigned int)(rq->pr.reqlen - 2)

$10 = 4294967295
gdb
print (size_t)(rq->pr.reqlen - 2)

$11 = 4294967295
gdb
x/8bx rq->request

0x616000010008: 0x20 0xbe 0xbe 0xbe 0xbe 0xbe 0xbe 0xbe

Observed stack:

#0  mcplib_request_raw_line
#1  luaD_precall
#2  luaV_execute
#6  lua_resume
#7  proxy_run_rcontext
#8  proxy_process_command
#9  try_read_command_proxy

This confirms that the crash path is reached through the proxy request execution path rather than by directly calling an internal C function from a debugger.


ASan evidence

Here is the Full ASan
bash
=================================================================
==245055==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6160000101fc at pc 0x58b4bec3df54 bp 0x72ad0abf1e40 sp 0x72ad0abf1608
READ of size 4294967295 at 0x6160000101fc thread T5
    #0 0x58b4bec3df53 in __interceptor_memcpy (/home/weichuan/wc-own/6-5/memcached/build-asan/memcached-debug+0x1a0f53) (BuildId: 7cde1af9b4bf24591e4279859c1d16432edd7ad4)
    #1 0x58b4bef06510 in memcpy /usr/include/x86_64-linux-gnu/bits/string_fortified.h:29:10
    #2 0x58b4bef06510 in luaS_newlstr /home/weichuan/wc-own/6-5/memcached/build-asan/vendor/lua/src/lstring.c:229:5
    #3 0x58b4beef7ee4 in lua_pushlstring /home/weichuan/wc-own/6-5/memcached/build-asan/vendor/lua/src/lapi.c:523:39
    #4 0x58b4bee23fb4 in mcplib_request_raw_line /home/weichuan/wc-own/6-5/memcached/build-asan/../proxy_request.c:283:5
    #5 0x58b4beefc041 in luaD_precall /home/weichuan/wc-own/6-5/memcached/build-asan/vendor/lua/src/ldo.c:532:12
    #6 0x58b4bef0abdb in luaV_execute /home/weichuan/wc-own/6-5/memcached/build-asan/vendor/lua/src/lvm.c:1624:22
    #7 0x58b4beefc1ee in ccall /home/weichuan/wc-own/6-5/memcached/build-asan/vendor/lua/src/ldo.c:577:5
    #8 0x58b4beefc1ee in resume /home/weichuan/wc-own/6-5/memcached/build-asan/vendor/lua/src/ldo.c:731:5
    #9 0x58b4beefb432 in luaD_rawrunprotected /home/weichuan/wc-own/6-5/memcached/build-asan/vendor/lua/src/ldo.c:144:3
    #10 0x58b4beefc3ee in lua_resume /home/weichuan/wc-own/6-5/memcached/build-asan/vendor/lua/src/ldo.c:788:12
    #11 0x58b4bee047a2 in proxy_run_rcontext /home/weichuan/wc-own/6-5/memcached/build-asan/../proto_proxy.c:822:17
    #12 0x58b4bee0b0c8 in proxy_process_command /home/weichuan/wc-own/6-5/memcached/build-asan/../proto_proxy.c:1216:5
    #13 0x58b4bee082c9 in try_read_command_proxy /home/weichuan/wc-own/6-5/memcached/build-asan/../proto_proxy.c:649:5
    #14 0x58b4bece3adc in drive_machine /home/weichuan/wc-own/6-5/memcached/build-asan/../memcached.c:3099:17
    #15 0x58b4becf5487 in event_handler /home/weichuan/wc-own/6-5/memcached/build-asan/../memcached.c:3377:5
    #16 0x72ad0f876f57  (/lib/x86_64-linux-gnu/libevent-2.1.so.7+0x1ff57) (BuildId: 4c26f7362d55dd3d9504a9fe94805a6235d480c7)
    #17 0x72ad0f8788a6 in event_base_loop (/lib/x86_64-linux-gnu/libevent-2.1.so.7+0x218a6) (BuildId: 4c26f7362d55dd3d9504a9fe94805a6235d480c7)
    #18 0x58b4bed61937 in worker_libevent /home/weichuan/wc-own/6-5/memcached/build-asan/../thread.c:525:9
    #19 0x72ad0ee94ac2 in start_thread nptl/./nptl/pthread_create.c:442:8
    #20 0x72ad0ef268cf  misc/../sysdeps/unix/sysv/linux/x86_64/clone3.S:81

0x6160000101fc is located 0 bytes to the right of 636-byte region [0x61600000ff80,0x6160000101fc)
allocated by thread T5 here:
    #0 0x58b4beca6f76 in __interceptor_realloc (/home/weichuan/wc-own/6-5/memcached/build-asan/memcached-debug+0x209f76) (BuildId: 7cde1af9b4bf24591e4279859c1d16432edd7ad4)
    #1 0x58b4bef004ee in luaM_malloc_ /home/weichuan/wc-own/6-5/memcached/build-asan/vendor/lua/src/lmem.c:192:22

Thread T5 created by T0 here:
    #0 0x58b4bec8ffcc in __interceptor_pthread_create (/home/weichuan/wc-own/6-5/memcached/build-asan/memcached-debug+0x1f2fcc) (BuildId: 7cde1af9b4bf24591e4279859c1d16432edd7ad4)
    #1 0x58b4bed60efa in create_worker /home/weichuan/wc-own/6-5/memcached/build-asan/../thread.c:381:16
    #2 0x58b4bed60efa in memcached_thread_init /home/weichuan/wc-own/6-5/memcached/build-asan/../thread.c:1150:9
    #3 0x58b4bed10b8c in main /home/weichuan/wc-own/6-5/memcached/build-asan/../memcached.c:6041:5
    #4 0x72ad0ee29d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

SUMMARY: AddressSanitizer: heap-buffer-overflow (/home/weichuan/wc-own/6-5/memcached/build-asan/memcached-debug+0x1a0f53) (BuildId: 7cde1af9b4bf24591e4279859c1d16432edd7ad4) in __interceptor_memcpy
Shadow bytes around the buggy address:
  0x0c2c7fff9fe0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c2c7fff9ff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c2c7fffa000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c2c7fffa010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c2c7fffa020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0c2c7fffa030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00[04]
  0x0c2c7fffa040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c2c7fffa050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c2c7fffa060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c2c7fffa070: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c2c7fffa080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==245055==ABORTING

Reproduction

Build ASan/UBSan version

bash
./autogen.sh

mkdir -p build-asan
ln -sfn ../vendor build-asan/vendor
cd build-asan

CC=clang \
CFLAGS="-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined" \
LDFLAGS="-fsanitize=address,undefined" \
../configure --enable-tls --enable-proxy

make -j"$(nproc)"

Proxy route used

The proxy config file used during local testing was:

proxy_request_new_rawline.lua
Relevant route config
lua
function mcp_config_pools()
    return true
end

function mcp_config_routes()
    local fg = mcp.funcgen_new()
    fg:ready({
        n = "blankreqrawline", u = 1, f = function(rctx)
            local nreq = rctx:request_new()
            return function(r)
                local line = nreq:raw_line()
                return "SERVER_ERROR " .. (line or "nil") .. "\r\n"
            end
        end
    })

    mcp.attach(mcp.CMD_MG, fg)
end

The important point is not the complexity of the client command. The client command only needs to enter the Lua route. The crash is caused by calling raw_line() on the placeholder request returned by request_new().

ASan reproduction

Terminal 1:

bash
./memcached -u root -p 11241 -U 0 -m 64 -t 1 -o proxy_config=proxy_request_new_rawline.lua -vv

Terminal 2:

bash
printf 'mg x\r\n' | nc 127.0.0.1 11241

Fix suggestion

Minimal safety fix

Add a shared validation helper for APIs that require a complete CRLF-terminated request line, and call it from mcplib_request_raw_line() before subtracting two bytes.

Suggested patch for proxy_request.c:

diff
diff --git a/proxy_request.c b/proxy_request.c
index XXXXXXX..YYYYYYY 100644
--- a/proxy_request.c
+++ b/proxy_request.c
@@ -1,6 +1,20 @@
 #include "proxy.h"
 #include "proto_parser.h"

+static bool mcp_request_has_crlf_line(const mcp_request_t *rq) {
+    if (rq == NULL) {
+        return false;
+    }
+    if (rq->pr.request == NULL) {
+        return false;
+    }
+    if (rq->pr.reqlen < 2) {
+        return false;
+    }
+    const char *end = rq->pr.request + rq->pr.reqlen - 2;
+    return end[0] == '\r' && end[1] == '\n';
+}
+
 // FIXME (v2): any reason to pass in command/cmdlen separately?
 mcp_request_t *mcp_new_request(lua_State *L, mcp_parser_t *pr, const char *command, size_t cmdlen) {
     mcp_request_t *rq = lua_newuserdatauv(L, sizeof(mcp_request_t) + MCP_REQUEST_MAXLEN, 0);
@@ -258,8 +272,13 @@ int mcplib_request_raw_line(lua_State *L) {
     mcp_request_t *rq = luaL_checkudata(L, 1, "mcp.request");
+    if (!mcp_request_has_crlf_line(rq)) {
+        proxy_lua_error(L, "raw_line(): request is not a complete CRLF-terminated request");
+        return 0;
+    }
     lua_pushlstring(L, rq->pr.request, rq->pr.reqlen-2);
     return 1;
 }

Harden other CRLF-dependent request APIs

mcp_request_append() currently does this before checking the request shape:

c
char *p = (char *)pr->request + pr->reqlen - 2; // start at the \r
assert(*p == '\r');

That function should also reject incomplete placeholder requests before pointer arithmetic:

diff
@@ -97,10 +111,15 @@ int mcp_request_app