Skip to content

max_string_bytes is not enforced by table.concat, string.gsub, string.format, string.pack or os.date #425

Description

@MrYawe

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions