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
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:
dest
to mem(cpy|move)
. This is
a corner case.or:
mem(cpy|move)
call that is to be
fortified. This is the normal case.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.