cassemblygccswitch-statementjump-table

Disabling sanity checks in generated jump tables


Consider the following switch construct. This simply switches on a variable x and prints a number.

switch (x)
{
case 1:
printf("1");
break;

case 2:
printf("2");
break;

case 3:
printf("3");
break;

case 4:
printf("4");
break;

case 5:
printf("5");
break;

case 6:
printf("6");
break;

case 7:
printf("7");
break;

case 9:
printf("9");
break;

case 18:
printf("18");
break;

case 11:
printf("11");
break;

case 12:
printf("12");
break;

case 15:
printf("15");
break;

default:
printf("0");
break;
}

The maximum case value here is 18. When compiled by gcc 9.4.0 using -O0, -01, -O2, -O3, -Os, -Ofast, all of the binaries had the switch implemented as a jump table, with a sanity check block as follows:

004011ab  8b45f8             mov     eax, dword [rbp-0x8 {var_10}]
004011ae  83f812             cmp     eax, 0x12
004011b1  0f87ba000000       ja      0x401271

Basically, this treats x as an unsigned value and checks if it's greater than 18 i.e. 0x12. Wikipedia entry for branch table states:

optionally validating the input data to ensure it is acceptable (this may occur without cost as part of the next step, if the input is a single byte and a 256 byte translate table is used to directly obtain the offset below). Also, if there is no doubt about the values of the input, this step can be omitted.

The sanity check mentioned in my example is an optional block as per wikipedia. But I wasn't able to generate such a binary which did not have the check in place. The gcc documentation mentions -fno-switch-tables to disable the generation of switch tables but that isn't what I am looking for.

The same behaviour was observed when default case was omitted.

Is it possible to do so? Or have all the major compilers dropped it in light of it being exploitable?


Solution

  • The same behaviour was observed when default case was omitted.

    In C, the behaviour is still well-defined with an x there's no case for. It's like there's an implicit default: at the bottom of the switch block, so it has to safely do nothing for out-of-range x values. So the check can't be omitted.

    To tell the compiler that you don't care what happens for cases other than the explicit ones, GNU C default: __builtin_unreachable(); gets GCC and Clang to omit the range check. It would be undefined behaviour for x to be anything other than 1..18 so they can assume it's in that range. There are other ways you could make a promise to the compiler, like __builtin_assume I think, or as Chux suggests by doing something portable that forces out-of-range values into something in a small range, like x %= 32u. That will result in an AND instruction, so still not totally free.

    Godbolt shows GCC and Clang compiling void foo(long x){ switch(x) { ...}} to jmp [QWORD PTR .L4[0+rdi*8]] as the first instruction of the function for non-PIE with GCC, or to a lookup in a table of 32-bit relative offsets for Clang where I used -fPIE. (The Godbolt default config is -fPIE for recent Clang, -fno-pie for GCC.)


    In this specific case, where the cases only differ in data

    In this case it would be simpler and more efficient to have an array of static const char *const strings[] = {"1", "2", ..., NULL, "18"}; and use fputs(strings[x-1], stdout) instead of a switch.

    This of course compiles to an array lookup of a pointer to a string, and has UB for x outside the 1-18 range so the compiler doesn't need to care about that case.

    (Your cases have gaps within the 1-18 range; null pointers for the unused entries makes the most sense; that's what Clang does if you make the cases similar enough for it to choose to optimize the switch into just a data lookup instead of a jump table. Godbolt)