$ cd ../
$ cat /backups/brain/
0049
Partial vs Full RELRO

Consider 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 ../