Skip to content

Security hardening: bound FASL sizes, refuse unsafe socket buffers, shell-quote save-lisp paths (S2–S10 except S1/S6)#1776

Open
dg1sbg wants to merge 7 commits into
clasp-developers:mainfrom
dg1sbg:sec/hardening
Open

Security hardening: bound FASL sizes, refuse unsafe socket buffers, shell-quote save-lisp paths (S2–S10 except S1/S6)#1776
dg1sbg wants to merge 7 commits into
clasp-developers:mainfrom
dg1sbg:sec/hardening

Conversation

@dg1sbg
Copy link
Copy Markdown
Contributor

@dg1sbg dg1sbg commented May 26, 2026

Summary

Six small, file-local security fixes addressing pre-existing vulnerabilities at the FASL loader, sockets FFI, save-lisp-and-die build path, exec-environment construction, and the LLVM symbol-lookup callback. Each commit is independent and reviewable on its own.

Threat-model framing

clasp has no internal security boundary by design — loading a FASL is arbitrary code execution, and any Lisp code already has full ext:system / FFI. These fixes therefore matter at real trust boundaries: loading FASLs from a less-trusted source, feeding network bytes to the socket primitives, or passing externally-influenced paths to save-lisp-and-die.

The fixes

commit issue fix
loadltv: validate FASL-supplied bignum and array sizes (S2) op_bignum did mp_limb_t limbs[std::abs(ssize)] — stack VLA sized from a file byte, with std::abs(INT64_MIN) UB and no upper bound. (S7) op_array's total *= dim could overflow size_t then be passed to make_vector/make_mdarray while the per-element fill walked the full unwrapped count → read/write past the buffer reject INT64_MIN, cap limbs at 1<<20, heap-allocate via std::vector; replace total *= dim with __builtin_mul_overflow and cap at 1<<30 elements
sockets: refuse unsafe buffer types + range-check fd_set fds (S3/S4) safe_buffer_pointer's (vector t) branch let recvfrom/send scribble raw network bytes over a vector of boxed Lisp pointers (GC heap corruption); its (size+divisor-1)/divisor capacity check wrapped on a negative length cast to uint. (S8) FD_SET/FD_CLR/FD_ISSET from Lisp had no range check, writing/reading out of bounds for fd >= FD_SETSIZE refuse anything other than strings or (simple-array (unsigned-byte 8)), reject negative size, change the param type to int; range-check fd in all three operators
serve-event: range-check fd_set fds (S8) same FD_SET/FD_ISSET issue in serve-event's variants range-check fd
save-lisp-and-die: shell-quote interpolated paths (S5) system(...) and popen(...) built command lines by string concatenation with filename/_FileName/_LibDir. (save-lisp-and-die :executable t :file "foo; rm -rf ~/work; #") would run the rm at link time small shell_quote helper (single-quote + escape internal '), wrap every user-controlled value before interpolation; build-time-constant BUILD_LINKFLAGS/BUILD_LIB stay unquoted, and mangled_name is already restricted to [A-Za-z0-9_]
unixsys: terminate env strings before advancing the index (S9) environ[j]=malloc(...); memcpy(...); j++; environ[j][l]=0; — the NUL-terminate fired after j++, so it wrote into the next, still-uninitialized pointer slot at offset l (wild write) and the actual string was never terminated move the terminate before j++
Bound the LLVM symbol-lookup callback buffer copy (S10) strcpy(global_LLVMSymbolLookupCallbackBuffer /* [1024] */, stringstream.str().c_str()) — buffer overflow when dbg_safe_repr emits a long object printout (JIT debug path) bounded memcpy that truncates to CALLBACK_BUFFER_SIZE-1 and NUL-terminates

What this PR does not do

  • (S1) No load-time bytecode verifier. With DEBUG_VIRTUAL_MACHINE off (release), VM bounds checks are compiled out, so a malformed FASL can do anything the VM can do. Adding a verifier is a much larger, separate design project — out of scope here.
  • (S6) FASL loader EOF check — already covered by Boot speedup: buffer FASL reads + cache file-scope (~2.6x faster boot) #1775 (the buffered loader errors cleanly on unexpected EOF).

Testing

Full clasp regression suite byte-identical to the pre-change baseline after the combined set: 1877 pass, 0 bus errors / segfaults / memory corruption. (Same 4 pre-existing/expected failures listed in set-unexpected-failures.lisp, unrelated.) The suite exercises FASL loading, the sockets FFI, and save-lisp-and-die paths.

🤖 Generated with Claude Code

dg1sbg and others added 7 commits May 26, 2026 08:16
Two file-supplied sizes in loadltv flowed straight into allocation/iteration
with no checking, so one byte of a malformed FASL could overflow the stack or
allocate tens of GB.

op_bignum:
  - mp_limb_t limbs[std::abs(ssize)] was a stack VLA sized straight from
    the file (stack overflow / smash).
  - std::abs(INT64_MIN) is undefined behaviour.
  - No upper bound on the limb count.
Reject INT64_MIN, cap at 1<<20 limbs (~8 MB bignum, very generous), and use
std::vector instead of a VLA.

op_array:
  - total *= dim could overflow size_t over up to 255 dimensions of u16, then
    the wrapped-around small value was passed to make_vector / make_mdarray
    while the per-element fill loop still walked the full unwrapped count =
    read/write past the allocated buffer.
Replace with __builtin_mul_overflow and cap total at 1G elements.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues in the sockets FFI layer that exported low-level socket primitives
to Lisp:

safe_buffer_pointer (used by ll-socket-receive / ll-socket-send-*):
  - The (vector t) branch (ComplexVector_T_O) let the caller hand recvfrom/
    send a vector of *boxed Lisp pointers*. recvfrom would then scribble raw
    network bytes over the GC's tagged pointer values, corrupting the heap.
  - Its capacity check did size=(size+divisor-1)/divisor on a uint, which
    wrapped when length (signed int, passed in from Lisp) was negative or
    huge; the resulting small size then passed the bounds check and the
    syscall ran with the original signed length cast to size_t = multi-GB OOB.
  Refuse non-byte/char buffers altogether (high-level wrappers already use
  strings or (simple-array (unsigned-byte 8))), and reject negative size at
  the entry. Change the parameter type from uint to int so the negativity
  check is well-defined.

FD_SET / FD_CLR / FD_ISSET (sockets_internal__fdset_*):
  - Took fd from Lisp with no range check. FD_SET on an fd outside
    [0, FD_SETSIZE) writes out of bounds of the fixed bit array.
  Range-check fd via a shared check_fdset_fd helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
serve_event_internal__ll_fd_set / ll_fd_isset took fd from Lisp and called
FD_SET / FD_ISSET with no bounds check; an fd outside [0, FD_SETSIZE) writes
or reads out of bounds of the fixed bit array.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The snapshot save/load code built shell command lines by string concatenation
and ran them through system() / popen():
  - popen("nm \"" + filename + "\"", "r")       (loadLibrarySymbolLookup)
  - system(OBJCOPY_BINARY " ... " + filename + ...)   (Linux objcopy)
  - system(CXX_BINARY " ... -o" + _FileName + ...)    (Linux+Darwin link)
A filename / library-dir containing shell metacharacters (or just a double
quote) would inject commands -- for example save-lisp-and-die with
  :executable t :file "foo; rm -rf ~/work; #"
would run the rm at link time.

Add a small shell_quote helper (single-quote, escape embedded ' as '\\'')
and wrap every user-controlled value (filename, _FileName, _LibDir,
obj_filename) before interpolating it. BUILD_LINKFLAGS / BUILD_LIB / the
SNAPSHOT_* macros come from clasp's own config.h, not from Lisp, and stay
unquoted. mangled_name is already restricted to [A-Za-z0-9_] and stays
unquoted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The exec-environment build loop did:
    environ[j] = (char*)malloc(l + 1);
    memcpy(environ[j], ss.c_str(), l);
    j++;
    environ[j][l] = 0;
The null-terminate fired AFTER j++, so it wrote into environ[j] (the next,
still-uninitialized pointer slot) at offset l = wild write at an
uncontrolled address + the actual string was never NUL-terminated. Move the
terminate before j++.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The LLVM symbol-lookup callback strcpy'd the result of a stringstream into
a fixed 1 KB buffer (global_LLVMSymbolLookupCallbackBuffer[CALLBACK_BUFFER_SIZE]).
The stringstream can exceed that -- e.g. when dbg_safe_repr emits a long
object printout -- so the strcpy could overflow.

Replace with a bounded memcpy that truncates to CALLBACK_BUFFER_SIZE-1 bytes
and NUL-terminates. This is a JIT debug/diagnostic path, so a clipped name
is acceptable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lu_procname's give-up branch (when the function name didn't fit even at
nbytes=4096) did:
    char fnamedot[nbytes + 3];
    fnamedot[0] = '\0';
    strcat(fnamedot, fname);
    strcat(fnamedot, "...");
This assumes fname is NUL-terminated, but unw_get_proc_name returned
UNW_ENOMEM -- meaning the buffer was too small, and libunwind may have
filled all nbytes bytes without writing a terminator. strcat then reads
off the end of fname into the surrounding stack until it finds a NUL,
and writes that (potentially much longer) content into fnamedot = OOB
read + write.

Bound the read with strnlen and build the result with std::string instead
of strcat, eliminating both hazards. Backtrace formatting is called from
the debugger, which is exactly the path you do not want to crash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant