gcccmakeg++

Why does _FORTIFY_SOURCE seem to have no effect in the resulting binary?


I am trying to enable _FORTIFY_SOURCE to add buffer overflow protections in our C++ projects, but when I compile and analyze the resulting binary, it seems like _FORTIFY_SOURCE has no effect. When I check the binary using checksec or look for fortified functions with readelf -sW myprogram | grep __chk, I don't see any fortified functions. All other compilation flags, except for fortify, produce the expected checksec output.

If anyone is interested in verifying whether it works as expected, we have a Minimal, Verifiable, Complete Example (MVCE) available here: Stoppable_MVE

It makes use of memcpy and memmove, which should be fortifiable functions.

The compiler settings file can be found at: compiler_settings.cmake

I tried playing around with different optimization levels (-O2, -O3) and _FORTIFY_SOURCE (2, 3) settings by modifying cmake/compiler_settings.cmake file.

I then start compilation using cmake:

$ mkdir build && cd build
$ cmake .. -DCMAKE_BUILD_TYPE=Release
$ cmake --build . --target all --config Release --

The result of checksec is always the same:

$ checksec --fortify-file=./sources/Example/Stoppable_Example
* FORTIFY_SOURCE support available (libc)    : Yes
* Binary compiled with FORTIFY_SOURCE support: No

 ------ EXECUTABLE-FILE ------- . -------- LIBC --------
 Fortifiable library functions | Checked function names
 -------------------------------------------------------
 memcpy                         | __memcpy_chk
 memmove                        | __memmove_chk

SUMMARY:

* Number of checked functions in libc                : 83
* Total number of library functions in the executable: 270
* Number of Fortifiable functions in the executable : 2
* Number of checked functions in the executable      : 0
* Number of unchecked functions in the executable    : 2


Solution

  • Here is a Minimal Reproducible Example of a program that exhibits the problem behaviour you observe in your gitgub repo and is also actually vulnerable to memcpy and memmove buffer-overflows such as _FORTIFY_SOURCE can pre-empt (among other things).

    $ cat maina.cpp
    #include <string>
    #include <cstdio>
    #include <cassert>
    
    using namespace std;
    
    int main(int argc, char *argv[])
    {
        assert(argc > 1);
        size_t copylen = strtoul(argv[1],NULL,10);
        size_t movelen = strtoul(argv[2],NULL,10);
        char buf[16] = {0};
        char_traits<char>::copy(buf,argv[3],copylen);
        printf("[%.15s]\n",buf);
        char_traits<char>::move(buf,argv[3],movelen);
        printf("[%.15s]\n",buf);
        exit(0);
    }
    

    When compiled with optimisation:

    $ g++ --version | head -n1
    g++ (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0
    
    $ g++ -Wall -Wextra -Wpedantic -O2 maina.cpp 
    

    we see that:

    $ readelf --dyn-syms --wide  a.out | grep mem
         6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memcpy@GLIBC_2.14 (5)
         8: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memmove@GLIBC_2.2.5 (4)
        
    

    the executable makes external references to the fortifiable functions memcpy and memmove. That is because they are eventually referenced respectively in the C++ Library internals of this program (and yours) from:

    char *
    std::char_traits<char>::copy( char* dest, const char* src, std::size_t count )
    

    and:

    char *
    std::char_traits<char>::move( char* dest, const char* src, std::size_t count )
    

    But when we attempt to fortify these references:

    $ g++ -Wall -Wextra -Wpedantic -D_FORTIFY_SOURCE=3 -O2 maina.cpp
    $ readelf --dyn-syms --wide  a.out | grep mem
         6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memcpy@GLIBC_2.14 (5)
         8: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memmove@GLIBC_2.2.5 (4)
             
    

    they remain unfortified:

    $ checksec --fortify-file=a.out
    * FORTIFY_SOURCE support available (libc)    : Yes
    * Binary compiled with FORTIFY_SOURCE support: Yes
    
     ------ EXECUTABLE-FILE ------- . -------- LIBC --------
     Fortifiable library functions | Checked function names
     -------------------------------------------------------
     printf_chk                     | __printf_chk
     printf_chk                     | __printf_chk
     memcpy                         | __memcpy_chk
     memcpy                         | __memcpy_chk
     memmove                        | __memmove_chk
     memmove                        | __memmove_chk
    
    SUMMARY:
    
    * Number of checked functions in libc                : 84
    * Total number of library functions in the executable: 59
    * Number of Fortifiable functions in the executable : 6
    * Number of checked functions in the executable      : 2
    * Number of unchecked functions in the executable    : 4
    

    (Each fortified or unfortified function is double-counted because checksec reports on both the dynamic symbol table and the global symbol table of the unstripped executable).

    What's broken?

    _FORTIFY_SOURCE is operative at source translation level, directing the compiler to refactor calls to fortifiable functions foo into calls to their fortified variants foo_chk, where possible . Where possible means where the scope of the call enables the compiler to deduce the values of the additional argument(s) required by foo_chk. If that is not possible then the compiler cannot replace the foo call with a foo_chk call.

    The signatures of the fortified memcpy, memmove compare with those of the fortified variants as:

    void* memcpy( void* dest, const void* src, std::size_t count );
    void * __memcpy_chk(void * dest, const void * src, size_t len, size_t destlen);
    

    and:

    void* memmove( void* dest, const void* src, std::size_t count );
    void * __memmove_chk(void * dest, const void * src, size_t len, size_t destlen);
    

    Each of the fortified variants requires an additional parameter destlen not provided to the mem(cpy|move) call giving the size of the region at dest into which the count bytes at src are to be copied or moved. The compiler must be able to conclusively deduce destlen itself from its knowledge of the calling context, and it can do this only if the allocation of those destlen bytes at dest is either:

    or:

    Let's call this condition the Mem Fortify Condition (MFC)

    In our program, memcpy and memmove are called only within the scope of std::char_traits<char>::copy and std::char_traits<char>::move respectively, and those functions, when they call mem(cpy|move), do so in a context of the form:

    std::char_traits<char>::(copy|move)( 
        char* dest, const char* src, std::size_t count ) {
        
            return mem(cpy|move)(dest,src,count);
    }
    

    (This discounts irrelevant other branches in the actual implementation of these library functions). The context fulfils neither part of the MFC. So these calls cannot be fortified. This is exactly the reason why the calls to these functions cannot be fortified in your program.

    Accordingly:

    $ ./a.out 8000 16 "Once upon a midnight dreary"
    Segmentation fault (core dumped)
    

    overflows buf in the copy and:

    $ ./a.out 16 4000 "Once upon a midnight dreary"
    [Once upon a mid]
    Segmentation fault (core dumped)
    

    overflows it in the move.

    Nothing is broken, but the MFC is unfulfilled and the program is vulnerable to buffer-overflows.

    What does not vulnerable look like?

    If we change the program as follows:

    $ cat mainb.cpp
    #include <cstring>
    #include <cstdio>
    #include <cassert>
    #include <cstdlib>
    
    using namespace std; 
    
    int main(int argc, char *argv[])
    {
        assert(argc > 1);
        size_t copylen = strtoul(argv[1],NULL,10);
        size_t movelen = strtoul(argv[2],NULL,10);
        char buf[16] = {0};
        memcpy(buf,argv[3],copylen);
        printf("[%.15s]\n",buf);
        memmove(buf,argv[3],movelen);
        printf("[%.15s]\n",buf);
        exit(0);
    }
    

    we have now brought the calls to memcpy and memmove into a function scope in which their context satifies the MFC: the array buf is allocated in the same scope as the mem(cpy|move) calls to which buf is passed as dest. So the compiler can fortify those calls:

    $ g++ -Wall -Wextra -Wpedantic -D_FORTIFY_SOURCE=3 -O2 mainb.cpp
    $ readelf --dyn-syms --wide  a.out | grep mem
         5: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __memmove_chk@GLIBC_2.3.4 (5)
         6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __memcpy_chk@GLIBC_2.3.4 (5)
     
    $ checksec --fortify-file=a.out
    * FORTIFY_SOURCE support available (libc)    : Yes
    * Binary compiled with FORTIFY_SOURCE support: Yes
    
     ------ EXECUTABLE-FILE ------- . -------- LIBC --------
     Fortifiable library functions | Checked function names
     -------------------------------------------------------
     printf_chk                     | __printf_chk
     printf_chk                     | __printf_chk
     memcpy_chk                     | __memcpy_chk
     memcpy_chk                     | __memcpy_chk
     memmove_chk                    | __memmove_chk
     memmove_chk                    | __memmove_chk
    
    SUMMARY:
    
    * Number of checked functions in libc                : 84
    * Total number of library functions in the executable: 59
    * Number of Fortifiable functions in the executable : 6
    * Number of checked functions in the executable      : 6
    * Number of unchecked functions in the executable    : 0
    

    and buffer overflows are pre-empted in both the copy and the move

    $ ./a.out 8000 16 "Once upon a midnight dreary"
    *** buffer overflow detected ***: terminated
    Aborted (core dumped)
    
    $ ./a.out 16 4000 "Once upon a midnight dreary"
    [Once upon a mid]
    *** buffer overflow detected ***: terminated
    Aborted (core dumped)
    

    For the same reason the following program has fortified mem(cpy|move):

    $ cat mainc.cpp
    #include <cstring>
    #include <cstdio>
    #include <cassert>
    #include <cstdlib>
    
    struct cls {
        char buf[16] = {0};
    };
    
    using namespace std;
    
    cls new_cls_memcpy(char const *src, size_t n)
    {
        cls out;
        memcpy(out.buf,src,n);
        return out;
    }
    
    cls new_cls_memmove(char const *src, size_t n)
    {
        cls out;
        memmove(out.buf,src,n);
        return out;
    }
      
    int main(int argc, char *argv[])
    {
        assert(argc > 1);
        size_t copylen = strtoul(argv[1],NULL,10);
        size_t movelen = strtoul(argv[2],NULL,10);
        cls st = new_cls_memcpy(argv[3],copylen);
        printf("[%.15s]\n",st.buf);
        st = new_cls_memmove(argv[3],movelen);
        printf("[%.15s]\n",st.buf);
        exit(0);
    }
    
    $ g++ -Wall -Wextra -Wpedantic -D_FORTIFY_SOURCE=3 -O2 mainc.cpp
    $ readelf --dyn-syms --wide  a.out | grep mem
        6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __memmove_chk@GLIBC_2.3.4 (6)
        7: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __memcpy_chk@GLIBC_2.3.4 (6)
     
    

    _FORTIFY_SOURCE is not magic armour

    It is constrained in non-obvious ways. Especially in C++. That will not surprise you by now, but it's worth hitting the point harder with one example.

    Suppose you get a program to maintain that harbours functions relevantly similar to copy_mem_to_cls and move_mem_to_cls in this one:

    $ cat maind.cpp
    #include <cstring>
    #include <cstdio>
    #include <cassert>
    #include <cstdlib>
    
    struct cls {
        char buf[16] = {0};
    };
    
    using namespace std;
    
    cls copy_mem_to_cls(cls in, char const *src, size_t n)
    {
        memcpy(in.buf,src,n);
        return in;
    }
    
    cls move_mem_to_cls(cls in, char const *src, size_t n)
    {
        memmove(in.buf,src,n);
        return in;
    }
      
    int main(int argc, char *argv[])
    {
        assert(argc > 1);
        size_t copylen = strtoul(argv[1],NULL,10);
        size_t movelen = strtoul(argv[2],NULL,10);
        cls st;
        st = copy_mem_to_cls(st,argv[3],copylen);
        printf("[%.15s]\n",st.buf);
        st = move_mem_to_cls(st,argv[3],movelen);
        printf("[%.15s]\n",st.buf);
        exit(0);
    }
    

    This program has fortified mem(cpy|move):

    $ g++ -Wall -Wextra -Wpedantic -D_FORTIFY_SOURCE=3 -O2 maind.cpp
    imk@imk-Inspiron-14-Plus-7420:~/develop/so/scrap$ readelf --dyn-syms --wide  a.out | grep mem
         5: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __memmove_chk@GLIBC_2.3.4 (5)
         6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __memcpy_chk@GLIBC_2.3.4 (5)
         
    

    which you might or might not know or care about. But the (copy|move)_mem_to_cls functions look like lame-brained C++ to you - which they might well be - so you improve the program in the following vein:

    $ cat maine.cpp
    #include <cstring>
    #include <cstdio>
    #include <cassert>
    #include <cstdlib>
    
    struct cls {
        char buf[16] = {0};
    };
    
    using namespace std;
    
    void copy_mem_to_cls(cls & in, char const *src, size_t n)
    {
        memcpy(in.buf,src,n);
    }
    
    void move_mem_to_cls(cls & in, char const *src, size_t n)
    {
        memmove(in.buf,src,n);
    }
      
    int main(int argc, char *argv[])
    {
        assert(argc > 1);
        size_t copylen = strtoul(argv[1],NULL,10);
        size_t movelen = strtoul(argv[2],NULL,10);
        cls st;
        copy_mem_to_cls(st,argv[3],copylen);
        printf("[%.15s]\n",st.buf);
        move_mem_to_cls(st,argv[3],movelen);
        printf("[%.15s]\n",st.buf);
        exit(0);
    }
    

    getting rid of the pointless pass-by-value of in and the pointless returns of in. But then:

    $ g++ -Wall -Wextra -Wpedantic -D_FORTIFY_SOURCE=3 -O2 maine.cpp
    $ readelf --dyn-syms --wide  a.out | grep mem
         6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memcpy@GLIBC_2.14 (5)
         8: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memmove@GLIBC_2.2.5 (4)
         
    

    The program no longer has fortified mem(cpy|move). The pass-by-value of in had the obscure side-effect of bringing the allocation of in.buf - the dest argument of mem(cpy|move) - into the same scope as the mem(cpy|move) calls, enabling them to be fortified. Your improved C++ removes that protection.

    But on the bright side...

    Subtly constrained though they are, the protections afforded by at least _FORTIFY_SOURCE=2 have for a long time been implemented in mainstream distros' stock builds of GLIBC. You probably noticed that:

    $ g++ -Wall -Wextra -Wpedantic -D_FORTIFY_SOURCE=3 -O2 maina.cpp
    

    produced fortified printf, although it didn't fortify mem(cpy|move). But:

    $ g++ -Wall -Wextra -Wpedantic -O2 maina.cpp
    

    does that anyway without _FORTIFY_SOURCE:

    $ checksec --fortify-file=a.out
    * FORTIFY_SOURCE support available (libc)    : Yes
    * Binary compiled with FORTIFY_SOURCE support: Yes
    
     ------ EXECUTABLE-FILE ------- . -------- LIBC --------
     Fortifiable library functions | Checked function names
     -------------------------------------------------------
     printf_chk                     | __printf_chk
     printf_chk                     | __printf_chk
     memcpy                         | __memcpy_chk
     memcpy                         | __memcpy_chk
     memmove                        | __memmove_chk
     memmove                        | __memmove_chk
    
    SUMMARY:
    
    * Number of checked functions in libc                : 84
    * Total number of library functions in the executable: 59
    * Number of Fortifiable functions in the executable : 6
    * Number of checked functions in the executable      : 2
    * Number of unchecked functions in the executable    : 4
    

    Likewise the completely fortified:

    $ g++ -Wall -Wextra -Wpedantic -D_FORTIFY_SOURCE=3 -O2 mainb.cpp 
    

    produced fortified printf and fortified mem(cpy|move). But so does:

    $ g++ -Wall -Wextra -Wpedantic -O2 mainb.cpp 
    $ checksec --fortify-file=a.out
    * FORTIFY_SOURCE support available (libc)    : Yes
    * Binary compiled with FORTIFY_SOURCE support: Yes
    
     ------ EXECUTABLE-FILE ------- . -------- LIBC --------
     Fortifiable library functions | Checked function names
     -------------------------------------------------------
     printf_chk                     | __printf_chk
     printf_chk                     | __printf_chk
     memcpy_chk                     | __memcpy_chk
     memcpy_chk                     | __memcpy_chk
     memmove_chk                    | __memmove_chk
     memmove_chk                    | __memmove_chk
    
    SUMMARY:
    
    * Number of checked functions in libc                : 84
    * Total number of library functions in the executable: 59
    * Number of Fortifiable functions in the executable : 6
    * Number of checked functions in the executable      : 6
    * Number of unchecked functions in the executable    : 0
    

    My Ubuntu 24.04.2 LTS GLIBC is built with -D_FORTIFY_SOURCE=2 In principle -D_FORTIFY_SOURCE=3 somewhat improves fortification coverage v. -D_FORTIFY_SOURCE=2 but in this program it makes no difference, as we can see.

    So don't take it for granted that you must gain something by building your code with _FORTIFY_SOURCE, or by -D_FORTIFY_SOURCE=3 v. -D_FORTIFY_SOURCE=2. Running checksec before you define _FORTIFY_SOURCE will show you the fortification coverage you're getting out of the box. If defining _FORTIFY_SOURCE=N makes no difference, or _FORTIFY_SOURCE=N makes no difference v. _FORTIFY_SOURCE=N-1, it doesn't mean something is broken; it just means the compiler can't do any better. C++ cannot be as memory-secure as, say, Rust.