Skip to content
← Prism
Prism
Overview
Features
defer orelse zeroinit raw bounds-check auto-unreachable auto-static check
C in practice
goto skips init tag namespace struct padding strict aliasing char signedness signed overflow VLAs
Spec Draft Releases Blog
GitHub ↗

Variable length arrayswhat actually goes wrong, and what does not

The real problem with int a[n] is not performance and not the leak people describe. It is that there is no way to ask whether the allocation succeeded. If n is too large the program dies on the spot, and no return value, errno or signal handler gives you a way to do anything about it.

#The failure that has no error path

int main(int argc, char **argv) {
    long n = strtol(argv[1], 0, 10);
    char buf[n];
    buf[0] = 1; buf[n - 1] = 2;
    printf("allocated %ld bytes, ok\n", n);
}

GCC 11.4.0, 8 MiB stack limit:

nResult
1024allocated 1024 bytes, ok
8000000allocated 8000000 bytes, ok
100000000SIGSEGV

Compare the version you would have written with malloc: a null check, an error return, a log line, a 400 response. The VLA version has no equivalent, because the allocation is a stack pointer adjustment with no result to test. malloc failing is an afternoon; a VLA failing is a crash.

The crash is the good outcome. A single adjustment larger than the guard region moves the stack pointer past it entirely, so the first write lands in whatever is mapped below rather than in an unmapped page, and no fault is raised. That is Stack Clash, CVE-2017-1000364, and VLAs were one of the mechanisms used to reach it. The kernel response was to widen the stack guard gap from one page to 1 MiB, which raises the bar rather than removing it.

That, rather than any performance argument, is why the Linux kernel removed VLAs from its source tree and uses -Wvla to keep them out. Linus Torvalds put it as "using VLA's is actively bad not just for security worries, but simply because VLA's are a really horribly bad idea in general in the kernel". Kernel stacks are small and fixed, and there is nobody to return an error to.

#Recursion multiplies it

C23 6.2.4p7 says a VLA's lifetime runs from its declaration until execution leaves the scope, and that entering the scope recursively creates a new instance each time. That part is real, and it is where the stack actually disappears:

void rec(int n) {
    int vla[1024];        /* 4 KiB per frame */
    ...
    if (n) rec(n - 1);
}
recursion depth 200: 828800 bytes of stack consumed

Nothing about that is surprising once stated, and it is worth stating because a 4 KiB VLA in a recursive parser reads like a local variable and costs like a memory allocator with no free list. A fixed-size array in the same position has the same problem; the difference is that int vla[1024] is visible in the source and int vla[n] is not.

#The leak that does not happen

The commonly repeated claim is that a backward goto over a VLA declaration allocates a fresh array every pass without releasing the previous one, so the stack grows without bound. Measured, over 2000 iterations of exactly that shape:

survived 2000 iterations; deepest VLA was 0 bytes below the first

Not one byte of drift, and the standard agrees with GCC rather than with the folklore. Footnote 26 to C23 6.2.4p7 spells out what leaving the scope means: "Leaving the innermost block containing the declaration, or jumping to a point in that block or an embedded block prior to the declaration, leaves the scope of the declaration." A backward jump to a label above the declaration is leaving the scope, so the old instance's lifetime ends before the new one begins.

Growing the array each pass does not change it either: 50 iterations with a size that climbs from 256 to 12800 bytes moves the stack pointer 12544 bytes, which is the largest single allocation and not the sum of all of them.

This is worth being precise about because the construct is still not something to write. The forward-jump version is a constraint violation, the reasoning required to see that the backward version is safe depends on a footnote most readers have never opened, and a tool that rejects it is making a defensible call. But "it leaks" is not why.

#sizeof evaluates its operand

sizeof is a compile-time operator that does not evaluate what you give it. That is true except for variably modified types, where it has to be, and the difference is observable:

int n = 0;
int bump(void) { n++; return 4; }

int a[bump()];              /* n == 1 */
(void)sizeof(a);            /* n == 1: the size was recorded at the declaration */
(void)sizeof(int[bump()]);  /* n == 2: this type name is evaluated */

So sizeof on a VLA object reads a stored size and costs nothing extra, while sizeof on a VLA type name runs the size expression, side effects and all. Put a function call, an increment or a read of a volatile in there and it happens once per sizeof. The macro that looks pure is not.

#The one thing C does reject

Jumping forward into the scope of a variably modified identifier is a constraint violation, so the compiler must diagnose it. C23 6.8.7.2p1: "A goto statement shall not jump from outside the scope of an identifier having a variably modified type to inside the scope of that identifier."

error: jump into scope of identifier with variably modified type

An error with no flags, not even -pedantic, and the reason is allocation rather than initialization: reaching the label without executing the declaration means the array was never allocated and the compiler cannot generate a correct stack adjustment. It is the one member of this family C refuses outright, which is the subject of goto skips a variable initialization, where the same jump past an ordinary int x = 42; compiles in silence.

#Optional since C11

C99 made VLAs mandatory. C11 made them optional and added __STDC_NO_VLA__ for an implementation to advertise that it does not have them, which is how MSVC has never supported them and stayed conforming.

C23 splits the feature. The macro now means specifically that the implementation does not support "variable length arrays with automatic storage duration"; variably modified parameters are mandatory, because void f(int n, int a[n]) adjusts to a pointer and costs nothing. So int a[n] as a local is the optional part, and the array-parameter notation, which documents the relationship between a length and a pointer, is here to stay.

GCC 11.4.0 supports them and does not define the macro. -Wvla reports each one:

warning: ISO C90 forbids variable length array 'a' [-Wvla]

The C90 wording is a GCC quirk rather than a claim about your standard version; the flag is the way to keep them out of a codebase regardless of dialect.

#What to write instead

A fixed upper bound plus a check. If the size has a real maximum, declare that and reject anything larger. char buf[4096]; if (n > sizeof buf) return -1; is the whole fix and it has an error path.

malloc and free above the bound. Slower per call and testable, which for an attacker-controlled size is the trade you want. Pair it with defer or a single cleanup label so the free is not duplicated on every exit.

Variably modified parameters, which are not the problem. void f(size_t rows, size_t cols, int m[rows][cols]) allocates nothing. It gives the compiler the shape for indexing and gives the reader the relationship between the arguments. Keep this one.

-Wvla. A blunt policy flag: every VLA, no exceptions, which is what you want if the answer is "none of these". Its softer sibling -Wvla-larger-than= is documented as warning "on unbounded uses of variable-length arrays", but on GCC 11.4.0 it produced nothing for void f(int n) { int a[n]; } at any level or byte threshold tried. -Walloca-larger-than= on the equivalent alloca fires immediately. Check that the flag does something on your compiler before treating it as coverage.

Not alloca. It has the same failure mode with worse scoping: the memory lives until the function returns rather than until the block ends, so an alloca in a loop genuinely does accumulate, which is the leak this page's third section says VLAs do not have.

#How Prism handles it

Prism tracks VLA declarations through its scope tree and rejects jumps that interact with them, including the backward form:

vla.c:11: pparse_error:     if (++i < 50) goto again;
                                         ^ goto 'again' loops over a variable-length array declaration;
                                           each iteration allocates a new VLA without freeing the
                                           previous one, causing unbounded stack growth

Rejecting it is defensible. The explanation is stronger than what GCC 11.4.0 does, which is the 0-byte result measured above. Read the diagnostic as "this construct is not worth reasoning about" rather than as a description of your binary.

Elsewhere Prism treats VLAs as a distinct case rather than a normal array, because a VLA cannot take an initializer list. Zero-initialization of int a[n]; emits a memset where a fixed-size array would get = {0}:

int f(int n) { int a[n]; return a[0]; }

/* prism transpile emits: */
int f(int n) { int a[n]; __builtin_memset(&a, 0, sizeof(a)); return a[/* bounds check */]; }

#Further reading

Measured on GCC 11.4.0, Ubuntu 22.04, aarch64, ulimit -s 8192 KiB. The value of n that crashes depends on your stack limit; that some value does is the point.