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
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 tosave-lisp-and-die.The fixes
loadltv: validate FASL-supplied bignum and array sizesop_bignumdidmp_limb_t limbs[std::abs(ssize)]— stack VLA sized from a file byte, withstd::abs(INT64_MIN)UB and no upper bound. (S7)op_array'stotal *= dimcould overflowsize_tthen be passed tomake_vector/make_mdarraywhile the per-element fill walked the full unwrapped count → read/write past the bufferINT64_MIN, cap limbs at1<<20, heap-allocate viastd::vector; replacetotal *= dimwith__builtin_mul_overflowand cap at1<<30elementssockets: refuse unsafe buffer types + range-check fd_set fdssafe_buffer_pointer's(vector t)branch letrecvfrom/sendscribble raw network bytes over a vector of boxed Lisp pointers (GC heap corruption); its(size+divisor-1)/divisorcapacity check wrapped on a negativelengthcast touint. (S8)FD_SET/FD_CLR/FD_ISSETfrom Lisp had no range check, writing/reading out of bounds forfd >= FD_SETSIZE(simple-array (unsigned-byte 8)), reject negative size, change the param type toint; range-check fd in all three operatorsserve-event: range-check fd_set fdsFD_SET/FD_ISSETissue in serve-event's variantssave-lisp-and-die: shell-quote interpolated pathssystem(...)andpopen(...)built command lines by string concatenation withfilename/_FileName/_LibDir.(save-lisp-and-die :executable t :file "foo; rm -rf ~/work; #")would run thermat link timeshell_quotehelper (single-quote + escape internal'), wrap every user-controlled value before interpolation; build-time-constantBUILD_LINKFLAGS/BUILD_LIBstay unquoted, andmangled_nameis already restricted to[A-Za-z0-9_]unixsys: terminate env strings before advancing the indexenviron[j]=malloc(...); memcpy(...); j++; environ[j][l]=0;— the NUL-terminate fired afterj++, so it wrote into the next, still-uninitialized pointer slot at offsetl(wild write) and the actual string was never terminatedj++Bound the LLVM symbol-lookup callback buffer copystrcpy(global_LLVMSymbolLookupCallbackBuffer /* [1024] */, stringstream.str().c_str())— buffer overflow whendbg_safe_repremits a long object printout (JIT debug path)memcpythat truncates toCALLBACK_BUFFER_SIZE-1and NUL-terminatesWhat this PR does not do
DEBUG_VIRTUAL_MACHINEoff (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.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, andsave-lisp-and-diepaths.🤖 Generated with Claude Code