1

In an attempt to avoid stack clash attacks against a program, we tried to set a limit on the stack size with setrlimit(RLIMIT_STACK) to about 2 MB.

This limit is fine for our program's own internal needs, but we then noticed that attempts to exec() external programs began to fail on some systems with this new limit. One system we investigated using the test program below seems to have a minimum stack size for exec()'d programs of a bit over 4 MiB.

My question is, how can we know the safe minimum value for the stack size on a given system, so that exec() will not fail?

We don't want to just raise this until things stop failing on all the systems we currently test against, since that is likely to cause failures in the future as the program is ported to newer system types with higher minimum requirements.

The C test program below is written in terms of system(), but the lower-level symptom is a failure in the execl() syscall. Depending on the host OS you test on, you either get errno == E2BIG or a segfault in the called program when you give the called program too little stack space to start up.

Build with:

$ CFLAGS="-std=c99 -D_POSIX_C_SOURCE=200809" make stacklim

This question is tangentially-related to "To check the E2BIG error condition in exec", but our actual question is different: we're interested in the potential portability problem setting this limit causes.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <unistd.h>

static enum try_code {
    raise_minimum,
    lower_maximum,
    failed_to_launch
}
try_stack_limit(rlim_t try)
{
    // Update the stack limit to the given value
    struct rlimit x;
    getrlimit(RLIMIT_STACK, &x);
    static int first_time = 1;
    if (first_time) {
        first_time = 0;
        printf("The default stack limit here is %llu bytes.\n", x.rlim_cur);
    }
    x.rlim_cur = try;
    setrlimit(RLIMIT_STACK, &x);

    // Test the limit with a do-nothing shell launch
    int status = system("exit");
    switch (status) {
        case 0: return lower_maximum;

        case -1:
            perror("Failed to start shell");
            return failed_to_launch;

        default:
            if (WIFEXITED(status) && WEXITSTATUS(status) == 127) {
                // system() couldn't run /bin/sh, so assume it was
                // because we set the stack limit too low.
                return raise_minimum;
            }
            else if (WIFSIGNALED(status)) {
                fprintf(stderr, "system() failed with signal %s.\n",
                        strsignal(WTERMSIG(status)));
                return failed_to_launch;
            }
            else {
                fprintf(stderr, "system() failed: %d.\n", status);
                return failed_to_launch;
            }
    }
}

int main(void)
{
    extern char **environ;
    size_t etot = 0;
    for (size_t i = 0; environ[i]; ++i) {
        etot += strlen(environ[i]) + 1;
    }
    printf("Environment size = %lu\n", etot + sizeof(char*));

    size_t tries = 0;
    rlim_t try = 1 * 1000 * 1000, min = 0, max = 0;
    while (1) {
        enum try_code code = try_stack_limit(try);
        switch (code) {
            case lower_maximum:
                // Call succeded, so lower max and try a lower limit.
                ++tries;
                max = try;
                printf("Lowered max to %llu bytes.\n", max);
                try = min + ((max - min) / 2);
                break;

            case failed_to_launch:
                if (tries == 0) {
                    // Our first try failed, so there may be a bug in
                    // the system() call.  Stop immediately.
                    return 2;
                }
                // Else, consider it a failure of the new limit, and
                // assume we need to limit it.

            case raise_minimum:
                // Call failed, so raise minimum and try a higher limit.
                ++tries;
                min = try > min ? try : min;
                rlim_t next = max ?
                        min + ((max - min) / 2) :
                        try * 2;
                if (next == try) {
                    printf("Min stack size here for exec is %llu.\n", max);
                    return 0;
                }
                else {
                    printf("Raising limit from %llu to %llu.\n", try, next);
                    try = next;
                }
                break;

            default:
                return 1;
        }
    }
}
Warren Young
  • 40,875
  • 8
  • 85
  • 101
  • Did you try calling `getrlimit` at startup, saving the value, then calling `setrlimit` with that value before calling `execl`? – dbush Jun 23 '17 at 18:21
  • I don't even understand the problem: 'But if it grows too much and gets too close to another memory region, the program may confuse the stack with the other memory region.' umm.. how, without generating a page fault? – ThingyWotsit Jun 23 '17 at 18:35
  • 1
    If you're attempting to prevent exploitation of stack clash by setting resource limits, then it is to be expected that some programs will fail to run with the limit you specify. Essentially, this is how that approach is effective -- to the extent that it is effective -- in addressing the problem. It is by no means clear that there is any limit you could set that would allow all good programs yet be adequately protective against the exploit. Certainly allowing whatever any program needs in order to run seems to completely defeat the purpose. – John Bollinger Jun 23 '17 at 18:35
  • 1
    @JohnBollinger: We're only interested in preventing stack clash attacks against our own program. If you try a too-low limit using the test program above against a more substantial program (say, Vim) you will notice that it doesn't even *launch* before dying. Surely you can't be saying that all programs start allocating megs of data on the stack before they even get launched? – Warren Young Jun 23 '17 at 18:42
  • IMO, this is a band-aid over the real solution: proper limit checks on array indexing. Just don't exceed fixed array sizes on the stack (e.g. `int arr[100]`) by checking limits, don't allow VLAs to get too large (also limits on `alloca`), and limit recursion, you won't get the stack spilling down into the heap area (which would require the stack to grow to terrabytes on a 64 bit system before they collide). Most pgms are built for the default stack size (e.g. Linux: 8MB)--so don't break them. Recode your program to check all limits rather than using `rlimit`. And what about the other pgms? – Craig Estey Jun 23 '17 at 18:46
  • You can only get the heap crashing into the stack if you index past the size of an array allocated there (or don't check `NULL` return from (e.g.) `malloc`, `sbrk`). You want to do the manual limit checks to prevent a stack array from going too far and overwriting the stack memory above (_including_ the fnc return address) to execute arbitrary code on the stack when the fnc returns--a more common attack vector. Or, exceeding one heap array to spill into another [usually aborts pgms but can be crafted as attack]. `rlimit` doesn't mitigate these. P.S: What's "Pascal"? :-) – Craig Estey Jun 23 '17 at 19:06
  • @WarrenYoung: That was true a long time ago. Things have changed a lot in the last 20-30 years. Too bad books of that time are still used when teaching OS design. – too honest for this site Jun 23 '17 at 19:09
  • @CraigEstey: My point is that you can moralize about the correct way to write programs all you like, but that doesn't prevent inadvertent vulnerabilities by itself. Guards like this, canaries of various sorts, etc. are valuable as well. If all it took were a list of best practices, we wouldn't keep seeing the same vulnerabilities come up again and again over decades of time. – Warren Young Jun 23 '17 at 19:10
  • On a system that has virtual memory, this whole exercise is somewhat moot - You can simply set the limit "really high" and rely on the system to allocate as much virtual pages you might need - Memory that's not used will simply stay in swap. – tofro Jun 23 '17 at 19:10
  • @WarrenYoung, in fact, in a sense I *am* saying that. `RLIMIT_STACK` is a limit on the size of the stack itself. How much of that space is (currently) *used* is an altogether different question. If a program's initial stack size is larger than your limit then it should fail at startup, even though very little of the requested space would have initially been in use. – John Bollinger Jun 23 '17 at 19:15
  • It seems that you're not reading the news. The question is about [this thing](https://blog.qualys.com/securitylabs/2017/06/19/the-stack-clash) where Qualys labs publicized that they were able to pretty much compromise everything on 32-bit systems. – Antti Haapala -- Слава Україні Jun 23 '17 at 19:48
  • Also, quoted from the web page: "(for example, in some cases our Sudo stack-clash exploit allocates merely 137MB of heap memory, and almost no stack memory); or your limits will be too low and will break legitimate applications." – Antti Haapala -- Слава Україні Jun 23 '17 at 19:49
  • I've used plenty of extra guard code to detect overflows [at runtime]. For a simple example, see my answer: https://stackoverflow.com/questions/34888974/is-it-possible-in-c-to-always-make-a-segfault-at-1-over-the-array-size/34889509#34889509 You can also add seed values to check for overflow with finer granularity and add a "canary" thread that constantly monitors the heap area. I've found, in practice, that overflow most frequently upwards. So, _my_ point is that the rlimit trick isn't nearly as useful (e.g. 2MB vs 8MB is relatively moot but adds complexity that can be a source of bugs) – Craig Estey Jun 23 '17 at 19:53
  • This isn't about overrunning a buffer at all. There is no **C-wise undefined behaviour** here, it is just that the compiler/runtime itself is broken. – Antti Haapala -- Слава Україні Jun 23 '17 at 19:59
  • Switch to a 64 bit OS. It's the right thing to do anyway given most modern processors run best in 64 bit mode. – rustyx Jun 25 '17 at 22:03
  • @RustyX: The system where the problem was discovered is CentOS 7, which only comes in a 64-bit version on Intel systems. Well, there's a community-built variant built for 32-bit systems, but we're not using that. – Warren Young Jun 25 '17 at 22:05

2 Answers2

0

Your program was launched successfully, therefore your program was implicitly given the correct stack size for launching other programs in turn: during your program's startup, get the current limit before you set the new lower limit:

struct rlimit g_default_stack_limit;  /* put in global scope */
getrlimit(RLIMIT_STACK, &g_default_stack_limit);

struct rlimit our_stack_limit;
memcpy(&our_stack_limit, &g_default_stack_limit, sizeof(our_stack_limit));
our_stack_limit.rlim_cur = 2000000;   /* some lower value */
setrlimit(RLIMIT_STACK, &our_stack_limit);

Then restore that initial value before launching an external program, and reapply the new limit after either the child fork() is created or a synchronous call of the program (e.g. via system()) exits:

struct rlimit our_stack_limit;
getrlimit(RLIMIT_STACK, &our_stack_limit);
setrlimit(RLIMIT_STACK, &g_default_stack_limit);

if (system(...) == 0) {
    ....
}

setrlimit(RLIMIT_STACK, &our_stack_limit);

This initial value may be the operating system's default or it may be a limit set by the program that called your program. Either way, it is almost certainly the correct starting value to pass on to programs your program calls in turn.

Warren Young
  • 40,875
  • 8
  • 85
  • 101
  • Did you check the size of the environment you are passing (implicitly) to the new executable? (For each element `ent` in `environ`, count `strlen(ent)+1+sizeof char*`.) If it turns out to be a megabyte or so on your Centos system, then mystery solved. – rici Jun 24 '17 at 06:43
  • @rici I've added a calculation for that to the test program above. On the CentOS system that requires over 4 MB of stack to exec another program, the environment size is about 2.4 kB. But on a nearly-unused CentOS 7 VM I tested on, the stack requirement was only in the tens of kB. Any more ideas? – Warren Young Jun 26 '17 at 18:40
  • no, but there is something here I'd love to track down. I have a small collection of apparently reliable reports of mysterious E2BIGs, but the only ones I can reproduce are the ones where the environment or the argv list has been somehow bloated. – rici Jun 26 '17 at 19:51
0

When calling exec(), the memory mapping for the calling process is invalidated. It is changed to accomodate the new executable. Stack memory, heap memory, global data and code memory are allocated for the new executable. The mappings are usually defined at link time, and the memory is allocated by the language libraries before the call to main()

ref : http://man7.org/linux/man-pages/man2/execve.2.html

Michaël Roy
  • 6,338
  • 1
  • 15
  • 19
  • 1
    That is true as far as it goes, but it doesn't explain why exec() fails if not enough memory is reserved for the stack in the calling program, nor how a caller can know what value it should reserve. To take my test result above, where does the 4-megs-and-change value come from on CentOS 7, and how can a program know that value before calling exec()? – Warren Young Jun 23 '17 at 22:05