$ cd ../
$ cat /backups/brain/
0049Partial vs Full RELROConsider the following program:
1
2
3
4
5
6
#include <stdio.h>
int main() {
printf("Hello world\n");
return 0;
}
And my compiler being:
1
2
[ecomaikgolf@laptop ~/relro/]$ gcc --version
gcc (GCC) 15.2.1 20250808 (Red Hat 15.2.1-1)
Let’s compile it four different times:
1
2
3
4
gcc -fPIE -pie -Wl,-z,relro,-z,now main.c -o full
gcc -fPIE -pie -Wl,-z,relro,-z,lazy main.c -o partial
gcc -fPIE -pie main.c -o default
gcc -fPIE -pie -Wl,-z,norelro main.c -o norelro
We see how RELRO is configured for each binary:
1
2
3
4
5
6
7
8
[ecomaikgolf@laptop ~/relro/]$ pwn checksec ./full 2>&1 | grep RELRO
RELRO: Full RELRO
[ecomaikgolf@laptop ~/relro/]$ pwn checksec ./partial 2>&1 | grep RELRO
RELRO: Partial RELRO
[ecomaikgolf@laptop ~/relro/]$ pwn checksec ./default 2>&1 | grep RELRO
RELRO: Partial RELRO
[ecomaikgolf@laptop ~/relro/]$ pwn checksec ./norelro 2>&1 | grep RELRO
RELRO: No RELRO
As a small sanity check:
1
2
[ecomaikgolf@laptop ~/relro/]$ pwn checksec $(which sshd) 2>&1 | grep RELRO
RELRO: Full RELRO
and for example
1
2
[ecomaikgolf@laptop ~/relro/]$ pwn checksec $(which gcc) 2>&1 | grep RELRO
RELRO: Partial RELRO
What’s not having RELRO?
Not having RELRO means that the GOT is writeable at runtime. This means that an
attacker with a 4-byte write primitive can overwrite the GOT and achieve flow
hijacking.
Before an after the GOT is resolved, the entry is still writeable memory:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
pwndbg> nearpc
b+ 0x55555555443d <main+4> lea rax, [rip + 0xe68] RAX => 0x5555555552ac ◂— 'Hello world'
0x555555554444 <main+11> mov rdi, rax
0x555555554447 <main+14> call puts@plt <puts@plt>
0x55555555444c <main+19> mov eax, 0 EAX => 0
0x555555554451 <main+24> pop rbp
► 0x555555554452 <main+25> ret <__libc_start_call_main+117>
0x555555554453 add bl, dh
pwndbg> got
Filtering out read-only entries (display them with -r or --show-readonly)
State of the GOT of /home/ecomaikgolf/relro/norelro:
GOT protection: No RELRO | Found 7 GOT entries passing the filter
[0x5555555565b8] __libc_start_main@GLIBC_2.34 -> 0x7ffff7db15a0 (__libc_start_main_impl) ◂— endbr64
[0x5555555565c0] _ITM_deregisterTMCloneTable -> 0
[0x5555555565c8] __gmon_start__ -> 0
[0x5555555565d0] _ITM_registerTMCloneTable -> 0
[0x5555555565d8] __cxa_finalize@GLIBC_2.2.5 -> 0x7ffff7dca080 (__cxa_finalize) ◂— endbr64
[0x5555555565f8] puts@GLIBC_2.2.5 -> 0x7ffff7e0c380 (puts) ◂— endbr64
[0x555555556600] __cxa_finalize@GLIBC_2.2.5 -> 0x555555554346 (__cxa_finalize@plt+6) ◂— push 1
pwndbg> vmmap 0x5555555565f8
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x555555555000 0x555555556000 r--p 1000 1000 norelro
► 0x555555556000 0x555555557000 rw-p 1000 1000 norelro +0x5f8
0x555555557000 0x555555578000 rw-p 21000 0 [heap]
pwndbg>
See how after it even was resolved to the correct puts
function, the memory is writeable:
► 0x555555556000 0x555555557000 rw-p 1000 1000 norelro +0x5f8
An attacker can overwrite the pointer of the puts
’s GOT to… system()
and
get RCE for example.
What’s having partial RELRO
Partial relro only makes .got
read only (non-PLT related entries), for
example libc_start_main
and others. .got.plt
contains the entries
(writeable) related to the PLT. So an attacker can just decide to attack the
later ones.
See:
1
2
3
4
5
6
7
8
9
10
State of the GOT of /home/ecomaikgolf/relro/partial:
GOT protection: Partial RELRO | Found 7 GOT entries passing the filter
[0x555555556fc0] __libc_start_main@GLIBC_2.34 -> 0x7ffff7db15a0 (__libc_start_main_impl) ◂— endbr64
[0x555555556fc8] _ITM_deregisterTMCloneTable -> 0
[0x555555556fd0] __gmon_start__ -> 0
[0x555555556fd8] _ITM_registerTMCloneTable -> 0
[0x555555556fe0] __cxa_finalize@GLIBC_2.2.5 -> 0x7ffff7dca080 (__cxa_finalize) ◂— endbr64
---
[0x555555557000] puts@GLIBC_2.2.5 -> 0x555555554376 (puts@plt+6) ◂— push 0 /* 'h' */
[0x555555557008] __cxa_finalize@GLIBC_2.2.5 -> 0x555555554386 (__cxa_finalize@plt+6) ◂— push 1
The last two entries are writeable
1
2
3
4
5
pwndbg> vmmap 0x555555557000
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x555555556000 0x555555557000 r--p 1000 1000 partial
► 0x555555557000 0x555555558000 rw-p 1000 2000 partial +0x0
and the rest is read-only.
Not completely nice but better than nothing I guess.
I’ve seen that having partial RELRO is better than no RELRO as the GOT
is
placed before the location of .bss
in memory, so that overflows in global
variables cannot easily achieve control flow hijacking easily. In my
experiments this is now the default behavior in any case. The norelro
and
partial
binaries both have the GOT before where the .bss
variables get
placed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
pwndbg> got
Filtering out read-only entries (display them with -r or --show-readonly)
State of the GOT of /home/ecomaikgolf/relro/norelro:
GOT protection: No RELRO | Found 7 GOT entries passing the filter
[0x5555555565b8] __libc_start_main@GLIBC_2.34 -> 0x7ffff7db15a0 (__libc_start_main_impl) ◂— endbr64
[0x5555555565c0] _ITM_deregisterTMCloneTable -> 0
[0x5555555565c8] __gmon_start__ -> 0
[0x5555555565d0] _ITM_registerTMCloneTable -> 0
[0x5555555565d8] __cxa_finalize@GLIBC_2.2.5 -> 0x7ffff7dca080 (__cxa_finalize) ◂— endbr64
[0x5555555565f8] puts@GLIBC_2.2.5 -> 0x555555554336 (puts@plt+6) ◂— push 0 /* 'h' */
[0x555555556600] __cxa_finalize@GLIBC_2.2.5 -> 0x555555554346 (__cxa_finalize@plt+6) ◂— push 1
pwndbg> dist test 0x5555555565b8
0x555555556640->0x5555555565b8 is -0x88 bytes (-0x11 words)
and also with readelf
(load offset is the last one):
1
2
3
4
5
6
7
8
# [Nr] Name Type Address Offset
# Size EntSize Flags Link Info Align
[24] .got PROGBITS 00000000000032f8 000022f8
0000000000000048 0000000000000008 WA 0 0 8
[25] .data PROGBITS 0000000000003340 00002340
0000000000000010 0000000000000000 WA 0 0 8
[26] .bss NOBITS 0000000000003360 00002350
0000000000000220 0000000000000000 WA 0 0 32
test
comes from the array:
1
2
3
4
5
6
char test[512];
int main() {
printf("Hello world\n");
return 0;
}
What’s having full RELRO
Full relro takes a decent startup performance hit. All .got
entries are
resolved before the program gets to call main()
. This is done by the
actual program entrypoint (libc). After all of the entries are resolved, the
VMA where the GOT is kept, is marked readonly. See:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pwndbg> print $rip
$3 = (void (*)()) 0x7ffff7fe47c0 <_start>
pwndbg> got -r
State of the GOT of /home/ecomaikgolf/relro/full:
GOT protection: Full RELRO | Found 3 GOT entries passing the filter
[0x403fe8] puts@GLIBC_2.2.5 -> 0x401036 (puts@plt+6) ◂— push 0 /* 'h' */
[0x403ff0] __libc_start_main@GLIBC_2.34 -> 0
[0x403ff8] __gmon_start__ -> 0
pwndbg> plt
Section .plt 0x401020 - 0x401040:
0x401030: puts@plt
pwndbg> vmmap 0x403fe8
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x402000 0x403000 r--p 1000 2000 full
► 0x403000 0x405000 rw-p 2000 2000 full +0xfe8
0x7ffff7fbf000 0x7ffff7fc3000 r--p 4000 0 [vvar]
pwndbg> vmmap 0x401020
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x400000 0x401000 r--p 1000 0 full
► 0x401000 0x402000 r-xp 1000 1000 full +0x20
0x402000 0x403000 r--p 1000 2000 full
See how at _start
the GOT memory is read/write.
Now if we continue till the first main()
instruction, we see that the GOT is
read-only:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pwndbg> print $rip
$4 = (void (*)()) 0x40112a <main+4>
pwndbg> got -r
State of the GOT of /home/ecomaikgolf/relro/full:
GOT protection: Full RELRO | Found 3 GOT entries passing the filter
[0x403fe8] puts@GLIBC_2.2.5 -> 0x7ffff7e0c380 (puts) ◂— endbr64
[0x403ff0] __libc_start_main@GLIBC_2.34 -> 0x7ffff7db15a0 (__libc_start_main_impl) ◂— endbr64
[0x403ff8] __gmon_start__ -> 0
pwndbg> plt
Section .plt 0x401020 - 0x401040:
0x401030: puts@plt
pwndbg> vmmap 0x403fe8
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x402000 0x403000 r--p 1000 2000 full
► 0x403000 0x404000 r--p 1000 2000 full +0xfe8
0x404000 0x405000 rw-p 1000 3000 full
pwndbg> vmmap 0x401020
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x400000 0x401000 r--p 1000 0 full
► 0x401000 0x402000 r-xp 1000 1000 full +0x20
0x402000 0x403000 r--p 1000 2000 full
You can see it at:
1
► 0x403000 0x404000 r--p 1000 2000 full +0xf
that now its r--p
.
You can also observe that the GOT
entry for puts
was puts@plt+6
during
_start
but at main
it arrived as 0x7ffff7e0c380 (puts) ◂— endbr64
(the
actual puts
function), even if we didn’t even call puts
yet!
With this, any write primitive that tries to overwrite the GOT to perform
control flow hijacking won’t succeed, as that region will be read-only now.
But was mentioned, the cost of having to resolve all GOT entries at the start
“to the correct one” involves some startup cost. Rest of runtime should be
“better” as you do not have to do that resolution later. I put better in quotes
as its (probably) negligible.
Note: you can measure ld
-related performance as:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
[ecomaikgolf@laptop ~/relro/]$ LD_DEBUG=statistics ./norelro
250052:
250052: runtime linker statistics:
250052: total startup time in dynamic loader: 55131 cycles
250052: time needed for relocation: 328 cycles (.5%)
250052: number of relocations: 98
250052: number of relocations from cache: 7
250052: number of relative relocations: 3
250052: time needed to load objects: 19042 cycles (34.5%)
Hello world
250052:
250052: runtime linker statistics:
250052: final number of relocations: 100
250052: final number of relocations from cache: 7
[ecomaikgolf@laptop ~/relro/]$ LD_DEBUG=statistics ./partial
250093:
250093: runtime linker statistics:
250093: total startup time in dynamic loader: 55092 cycles
250093: time needed for relocation: 669 cycles (1.2%)
250093: number of relocations: 98
250093: number of relocations from cache: 7
250093: number of relative relocations: 3
250093: time needed to load objects: 17426 cycles (31.6%)
Hello world
250093:
250093: runtime linker statistics:
250093: final number of relocations: 100
250093: final number of relocations from cache: 7
[ecomaikgolf@laptop ~/relro/]$ LD_DEBUG=statistics ./full
250097:
250097: runtime linker statistics:
250097: total startup time in dynamic loader: 56380 cycles
250097: time needed for relocation: 862 cycles (1.5%)
250097: number of relocations: 100
250097: number of relocations from cache: 7
250097: number of relative relocations: 3
250097: time needed to load objects: 17238 cycles (30.5%)
Hello world
250097:
250097: runtime linker statistics:
250097: final number of relocations: 100
250097: final number of relocations from cache: 7
[ecomaikgolf@laptop ~/relro/]$
Note that the program used is a very simple “Hello world” and the statistics
here have limited meaning.
$ cd ../