The sandboxing guide recommends setting :max_string_bytes below the :max_heap_size cap so that "string bombs are always refused deterministically and the heap cap stays what it should be: a backstop". That holds for string.rep and .., but table.concat, string.gsub, string.format, string.pack and os.date build their result without checking max_string_bytes. With the recommended setup, they still build strings past the ceiling in a single allocation.
Reproduction
Mix.install([{:lua, "1.0.2"}])
max = 1024 * 1024
scripts = [
{"string.rep", ~s|return #string.rep("x", 2 * 1024 * 1024)|},
{"..", ~s|local s = string.rep("x", 600 * 1024) return #(s .. s)|},
{"table.concat", ~s|local s = string.rep("x", 1024) local t = {} for i = 1, 2048 do t[i] = s end return #table.concat(t)|},
{"string.gsub", ~s|return #(string.gsub(string.rep("x", 2048), ".", string.rep("y", 1024)))|},
{"string.format",
~s|local s = string.rep("x", 1024) local t = {} for i = 1, 2048 do t[i] = s end return #string.format(string.rep("%s", 2048), table.unpack(t))|},
{"string.pack", ~s|return #string.pack("c2097152", "")|},
{"os.date", ~s|return #os.date(string.rep("%c", 100000))|}
]
for {name, script} <- scripts do
result =
try do
{[len], _} = Lua.eval!(Lua.new(max_string_bytes: max), script)
"built #{len} bytes (limit #{max})"
rescue
e -> "refused: " <> Exception.message(e)
end
IO.puts(String.pad_trailing(name, 14) <> result)
end
Output on 1.0.2 and on main (3f2aead), Elixir 1.20.4, OTP 29:
string.rep refused: Lua runtime error: resulting string too large (at <eval>:1)
.. refused: Lua runtime error: resulting string too large
table.concat built 2097152 bytes (limit 1048576)
string.gsub built 2097152 bytes (limit 1048576)
string.format built 2097152 bytes (limit 1048576)
string.pack built 2097152 bytes (limit 1048576)
os.date built 2400000 bytes (limit 1048576)
Expected: all seven refused with resulting string too large.
Why it matters
As the guide's warning says, max_heap_size is checked at garbage collection, so a single enormous allocation lands before the kill. These functions allow exactly that. A table can hold the same string many times for a few words each, so a script well under the heap cap can have table.concat of 200 references to a 1 MiB string allocate 200 MiB in one call.
string.pack also fits the guide's own description of "a size or count argument that an attacker can inflate": string.pack("c2147483647", "") asks for about 2 GiB in one :binary.copy/2. If the allocator can't satisfy it, the node aborts instead of the process being killed.
If these gaps are intended, it would help to list them in the guide's resource-limits table, since the pairing advice currently suggests otherwise.
Where
table.concat: table.ex#L247 joins the elements after only check_range_count!.
string.gsub: pattern.ex#L196 accumulates replacements (including %0–%9 expansions) and calls IO.iodata_to_binary/1 without a size check.
string.format: string.ex#L421 materializes the iolist without a size check.
string.pack: pack.ex#L344 and #L359 pad with :binary.copy/2 up to @max_total_size (0x7FFFFFFF), which only packsize enforces.
os.date: os.ex#L253 expands each directive (%c turns 2 bytes into 24) without a size check.
Possible fix
Call Limits.check_string_size!(size, state.max_string_bytes) before each allocation:
table.concat: check the sum of the element sizes plus the separators before Enum.join.
gsub: keep a running byte count in gsub_from's accumulator.
format and os.date: check IO.iodata_length/1 before IO.iodata_to_binary/1. That doesn't copy the pieces.
pack: check pos + n before each :binary.copy/2.
The sandboxing guide recommends setting
:max_string_bytesbelow the:max_heap_sizecap so that "string bombs are always refused deterministically and the heap cap stays what it should be: a backstop". That holds forstring.repand.., buttable.concat,string.gsub,string.format,string.packandos.datebuild their result without checkingmax_string_bytes. With the recommended setup, they still build strings past the ceiling in a single allocation.Reproduction
Output on 1.0.2 and on
main(3f2aead), Elixir 1.20.4, OTP 29:Expected: all seven refused with
resulting string too large.Why it matters
As the guide's warning says,
max_heap_sizeis checked at garbage collection, so a single enormous allocation lands before the kill. These functions allow exactly that. A table can hold the same string many times for a few words each, so a script well under the heap cap can havetable.concatof 200 references to a 1 MiB string allocate 200 MiB in one call.string.packalso fits the guide's own description of "a size or count argument that an attacker can inflate":string.pack("c2147483647", "")asks for about 2 GiB in one:binary.copy/2. If the allocator can't satisfy it, the node aborts instead of the process being killed.If these gaps are intended, it would help to list them in the guide's resource-limits table, since the pairing advice currently suggests otherwise.
Where
table.concat:table.ex#L247joins the elements after onlycheck_range_count!.string.gsub:pattern.ex#L196accumulates replacements (including%0–%9expansions) and callsIO.iodata_to_binary/1without a size check.string.format:string.ex#L421materializes the iolist without a size check.string.pack:pack.ex#L344and#L359pad with:binary.copy/2up to@max_total_size(0x7FFFFFFF), which onlypacksizeenforces.os.date:os.ex#L253expands each directive (%cturns 2 bytes into 24) without a size check.Possible fix
Call
Limits.check_string_size!(size, state.max_string_bytes)before each allocation:table.concat: check the sum of the element sizes plus the separators beforeEnum.join.gsub: keep a running byte count ingsub_from's accumulator.formatandos.date: checkIO.iodata_length/1beforeIO.iodata_to_binary/1. That doesn't copy the pieces.pack: checkpos + nbefore each:binary.copy/2.