Stack buffer overflow in `metacall.c` — is the 16-argument limit intentional?

Open
#923 2 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

@10vulture1005 is already working on this.

Since Oct 11, 2026.

  • #925 by @10vulture1005 — closed without merging
  • #926 by @10vulture1005 — open

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
28/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
c, python
Domain
backend

Research direction

Start with the argument-filling loop in source/metacall/source/metacall.c (lines 603-635), where a fixed METACALL_ARGS_SIZE array is written using signature_count(s), and compare it with the malloc-based pattern at py_loader_impl.c (268-273). The maintainers have not yet chosen between allocating to fit and checking and failing, and the same pattern appears in about twelve entry points, so get a decision before editing. Done means a chosen approach applied consistently, with a test that passes 17 or more arguments.

Written by the indexing model from the issue text.

Description

bug

🐛 Bug Report

What I'm proposing

I'm not opening a PR for this yet. I found something that looks like a real memory-safety bug, but
I can't tell whether the 16-argument limit is deliberate. So I'd like to ask:

  1. Is the limit intentional?
  2. If not, would you accept a fix, and which approach do you prefer?

Once we've agreed on scope, I'm happy to put together a solution.


What's happening

MetaCall lets a C program call functions written in other languages:

metacall_load_from_file("py", "script.py");
metacall("greet", "Alice");   // runs greet("Alice") in the Python script

To make that call, the C code gathers the arguments into an array.

The array is fixed-size:

#define METACALL_ARGS_SIZE 0x10      // 16

void *args[METACALL_ARGS_SIZE];      // holds 16

But the loop filling it uses a different number:

size_t args_count = signature_count(s);   // however many params the function declares

for (iterator = 0; iterator < args_count; ++iterator) {
    args[iterator] = value_create_bool(...);
}

s is the target function's signature, so args_count is however many parameters it takes.
Nothing compares args_count against METACALL_ARGS_SIZE.

A function declaring 17 parameters runs the loop 17 times, writing one element past the array and
overwriting adjacent stack memory — including the return address.

source/metacall/source/metacall.c:603-635


Why I think it's a bug rather than a designed limit

The limit is published as an API, but never enforced. metacall.h:222-228 exposes a function whose
entire job is to report it:

/**
*  @brief
*    Amount of function call arguments supported by MetaCall
*/
METACALL_API size_t metacall_args_size(void);

It answers "how many arguments do you support?" — and nothing on the receiving end checks. The
contract is documented, but the callee trusts every caller to honour it.

The same codebase already does this correctly. The Python loader handles the identical count,
three directories away, by allocating to fit (py_loader_impl.c:268-273):

const size_t args_size = signature_count(s);

if (args_size > 0)
{
    py_func->values = malloc(sizeof(PyObject *) * args_size);

Same signature_count(s) value, but the buffer is sized to match. To me this reads like the correct
pattern was understood in the codebase and just didn't carry over to the public C layer.

It's easy to trigger on purpose. Not an obscure edge case — a Python function with 17 declared
parameters is enough. The values written past the end are heap pointers from value_create_*, not
zeros.

It doesn't even need a loaded script. metacallfs() takes the count from a parsed JSON array
instead (metacall.c:1359/1377), so a document with a 20+ element array triggers it too.


Scope

Twelve entry points share the pattern — a fixed array filled from signature_count():

Function Array Count Count comes from
metacall 615 621 signature
metacallt 722 728 signature
metacallt_s 821 821 signature
metacallht_s 927 927 signature
metacallf 1243 1249 signature
metacallfmv 1430 1434 signature
metacallfms 1576 1594 signature
metacallfmv_await 1773 1773 signature
metacallfms_await 2023 2042 signature
metacall_register 1655 1663 caller parameter
metacallfs 1359 1377 JSON document
metacallfs_await 1919 1937 JSON document

Possible fixes

I'd rather not pick one unilaterally, since each has a tradeoff:

  1. Allocate to fit — malloc() from signature_count(s), mirroring py_loader_impl.c:268-273.
    Behaviour-preserving; high-arity calls keep working.
  2. Check and fail — return an error when args_count > METACALL_ARGS_SIZE. Smaller diff, but it
    turns a currently-working high-arity call into a hard failure, so it's an API behaviour change.
  3. Raise the constant — I'd argue against this alone. It doesn't add the missing check, it just
    moves the cliff from 16 to 32.

What I've done and haven't

Done: traced the count end-to-end — from PyObject_Size(args) in the Python loader, through
function_create() and signature_create(), to the loop writing into the array. Checked existing
issues first; the nearest are #907/#904 (off-by-one writes in portability_path_get_fullname/
get_directory and adt_set_small_insert), #855/#859, #636, #618, #811, #134 — none touch
metacall.c's argument handling. Searching METACALL_ARGS_SIZE and signature_count turns up
nothing.

Not done: I have not compiled this or run it under ASan, so I'm not claiming to have observed
a crash — this is from reading the code. I'd welcome a pointer to the right test harness, or
corrections if I've misread the intended semantics.

Context
  • Repo metacall/core @ 8f74e1fe (develop)
  • Static source review only; no build performed

Questions
  1. Is the 16-argument limit intentional? If so, is there a requirement that makes a fixed stack array
    appropriate here?
  2. If not, is this in scope for a contribution, and do you prefer option 1 or option 2?
  3. Also spotted: metacallfmv (1430) and metacallfmv_await (1773) declare args
    uninitialized and fill it sparsely at args[signature_get_index(s, key)], so any position not
    covered by a caller-supplied key reaches the loader as raw stack contents. This looks independent
    of the limit issue — should it be a separate report?
  4. Happy to split this into smaller issues if that's preferred.
Dominant language
C
Stars
1.8k
Forks
266
Avg merge
2d 16h
Merged PRs (30d)
14

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from metacall/core

All issues in metacall/core

Similar issues

More C issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.