luaO_pushvfstring reads past end of format string on trailing %

107 views
Skip to first unread message

Payo Nel

unread,
Aug 10, 2026, 6:15:39 PM (3 days ago) Aug 10
to 'TopchetoEU' via lua-l
Hi Roberto,
I found a bug in luaO_pushvfstring (lobject.c) where a format string ending in a lone % causes an out-of-bounds read.
The problem
while ((e = strchr(fmt, '%')) != NULL) {    ...    switch (*(e + 1)) {   /* reads the byte after '%' */    ...    default: {        addstr2buff(&buff, e, 2);  /* copies 2 bytes: '%' + the NUL terminator */        break;    }    }    fmt = e + 2;  /* points one byte past the NUL terminator */ }
uding the NUL into the result, embedding a '\0' in the output Lua string.

Repro:
#include <stdio.h> #include "lua.h" #include "lualib.h" #include "lauxlib.h" int main(void) {    lua_State *L = luaL_newstate();    lua_pushfstring(L, "hello %d end%", 42);    /* result contains embedded NUL: "hello 42 end%\0" with #s == 14 */    printf("len: %d\n", (int)lua_rawlen(L, -1));  /* prints 14, not 13 */    lua_close(L);    return 0; }

A solution
if (*(e + 1) == '\0') {   /* lone '%' at end of string */    addstr2buff(&buff, e, 1);  /* emit just the '%' */    break;                      /* done — no more format to scan */ }
Best Regards
Payo

Sainan

unread,
Aug 11, 2026, 4:55:10 AM (2 days ago) Aug 11
to lu...@googlegroups.com
Interesting edge case in theory, but are we sure this isn't a hallucination? Doesn't reproduce for me at all:

print(string.format("%f%", 100)) -- bad argument #3 to 'format' (no value)
print(string.format("%f%", 100, 0)) -- invalid conversion '%' to 'format'

-- Sainan

Francisco Olarte

unread,
Aug 11, 2026, 12:28:41 PM (2 days ago) Aug 11
to lu...@googlegroups.com
I think string.format does not use luaO_pushvfstring, like
push(v)fstring do. Those two are much more restricted on what they
accept.

Reproduction might also be fickle. It seems it copies the %\0 and
increments pointer just past the null, but if you have another null
just after it will not crash, just do the observed "adding %\0".

Anyway, feeding %\0 is "forbidden" : "Every occurrence of '%' in the
string fmt must form a valid conversion specifier. " and any C
programmer can SEGV with minimum effort, but a case for '\0' may be
useful, specially since the default behaviour is adding unknown
formats .

If I read the source right I think changing the while to

while ( ((e = strchr(fmt, '%')) != NULL) && *(e+1) ) {

Captures more faitfhfully the intention of "Set e to a format
specification, a two char sequence starting with %" by finding the %
and then insuring it points to an at-least two chars C substring.



Francisco Olarte.

Roberto Ierusalimschy

unread,
Aug 11, 2026, 1:54:46 PM (2 days ago) Aug 11
to lu...@googlegroups.com
> [...] but a case for '\0' may be
> useful, specially since the default behaviour is adding unknown
> formats .

You can use '%c' with a corresponding 0 to insert '\0'.

-- Roberto

gottfried leibniz

unread,
Aug 11, 2026, 10:11:41 PM (2 days ago) Aug 11
to lu...@googlegroups.com
ASan also reproduces and confirms this issue (and apologies for any
formatting issues):

└> clang -fsanitize=address -g -fno-omit-frame-pointer -I. -O2 test.c
liblua.a -lm

└> ./a.out
=================================================================
==11287==ERROR: AddressSanitizer: global-buffer-overflow on address
0xb0e37d0fe3ee at pc 0xb0e37cfa9514 bp 0xffffcdc30ba0 sp 0xffffcdc30390
READ of size 1 at 0xb0e37d0fe3ee thread T0
#0 0xb0e37cfa9510 in strchr (lua/a.out+0x59510) (BuildId:
f0de703ce44ff651f9cc906f255d8322f8543c58)
#1 0xb0e37d09f638 in luaO_pushvfstring lua/lobject.c:600:15
#2 0xb0e37d07cf04 in lua_pushfstring lua/lapi.c:602:3
#3 0xb0e37d078010 in main lua/test.c:8:5
#4 0xf7c3f9e02f18 in __libc_start_call_main
csu/../sysdeps/nptl/libc_start_call_main.h:59:16
#5 0xf7c3f9e03058 in __libc_start_main csu/../csu/libc-start.c:360:3
#6 0xb0e37cf8d0ec in _start (lua/a.out+0x3d0ec) (BuildId:
f0de703ce44ff651f9cc906f255d8322f8543c58)

0xb0e37d0fe3ee is located 18 bytes before global variable '.str.1'
defined in 'lua/test.c:10' (0xb0e37d0fe400) of size 9
'.str.1' is ascii string 'len: %d
'
0xb0e37d0fe3ee is located 0 bytes after global variable '.str' defined
in 'lua/test.c:8' (0xb0e37d0fe3e0) of size 14
'.str' is ascii string 'hello %d end%'
SUMMARY: AddressSanitizer: global-buffer-overflow (lua/a.out+0x59510)
(BuildId: f0de703ce44ff651f9cc906f255d8322f8543c58) in strchr

Francisco Olarte

unread,
Aug 12, 2026, 2:58:05 AM (yesterday) Aug 12
to lu...@googlegroups.com
Roberto:
Thanks, but I was not referring to that. I meant, since "%#", as "%$"
and all other invalid sequences just "keep unknown format in the
result", do the same for "%\0". Null inserting is not common enough
for the ( perceived by my ) target of this function to even suggest
"%0" for it, the %c combo is fine.

OTOH, as I commented, the input, fmt, is a C string, so "%\0" is not
really a two char substring but more of a OOB access, so I would
prefer some fix along the lines of testing e[1], if it is considered (
or just let it fall to a "unespecified ( by the manual ) behaviour",
but I think it would be better to just fix it, as well as keeping the
default handling, because formats like "Done up to %d%" or "Reached
%d% phase" are easy to key by mistake ( they plague my C code, thank
god GCC as printf checking ).

Francisco Olarte.

Roberto Ierusalimschy

unread,
Aug 12, 2026, 10:48:21 AM (yesterday) Aug 12
to lu...@googlegroups.com
> Thanks, but I was not referring to that. I meant, since "%#", as "%$"
> and all other invalid sequences just "keep unknown format in the
> result", do the same for "%\0". Null inserting is not common enough
> for the ( perceived by my ) target of this function to even suggest
> "%0" for it, the %c combo is fine.

The manual says this:

Every occurrence of % in the string fmt must form a valid conversion
specifier.

So, this 'just "keep unknown format in the result"' is undocumented
behavior. As you said, in C there are endless ways to shoot your own
foot, for instance by calling 'lua_pushfstring(L, "%s", 42l)'.
A '%' in the end of the format string is just one more way.

-- Roberto

Francisco Olarte

unread,
Aug 12, 2026, 11:32:23 AM (yesterday) Aug 12
to lu...@googlegroups.com
On Wed, 12 Aug 2026 at 16:48, Roberto Ierusalimschy
<rob...@inf.puc-rio.br> wrote:
> The manual says this:
> Every occurrence of % in the string fmt must form a valid conversion
> specifier.
> So, this 'just "keep unknown format in the result"' is undocumented
> behavior. As you said, in C there are endless ways to shoot your own
> foot, for instance by calling 'lua_pushfstring(L, "%s", 42l)'.
> A '%' in the end of the format string is just one more way.

I know, in fact I think I quoted that from the manual too.

I would like to wrap up a proposal ( for consideration in future versions).

- keep default in switch as is.
- Add "&& e[1]" just after strchr in while. If strchr succeeds e[1] is not UB.
- Let the tail after the while handle the dangling %.
- Change the manual to something like "Any occurrence of % in the
string fmt not starting a valid conversion specifier will be copied
to the output."

IMO this is a small change which, getting rid of the MUST, leads to
less UB in the manual.

Besides, and this is a personal taste, the e[1] check makes me feel
better as it insures e is a "valid C string with at least two chars (
i.e., strlen>=2 ) starting with '%' " before the switch.

Francisco Olarte.
Reply all
Reply to author
Forward
0 new messages