Stack Alignment in 32-bit program

TL;DR

부제: gcc 옵션 중 하나인 -mpreferred-stack-boundary에 대하여

Pwnable 문제를 풀다가 문득 32bit ROP를 살짝 잊었다는 기분이 들었다. (…) 예제 코드를 작성하고 그것을 익스하려고 했으나 생각대로 잘 되지 않아 분석을 진행하였다. 함수 프롤로그와 에필로그에 낯선 코드가 추가되어 있었고, 이 때문에 buf와 sfp 사이에 무언의 값이 들어있었다. 분석 결과, 원인은 gcc 옵션 중 하나인 -mpreferred-stack-boundary 때문임을 알게되었다.

Pwnable에서의 32bit 바이너리는 (거의) 대부분 -mpreferred-stack-boundary=2 옵션을 주고 컴파일을 한다.

gcc option: -mpreferred-stack-boundary=N

Attempt to keep the stack boundary aligned to a 2 raised to num byte boundary.
If ‘-mpreferred-stack-boundary’ is not specified, the default is 4 (16 bytes or
128 bits).

main을 포함하여 이후에 불리는 모든 함수들의 스택프레임에서 가장 낮은 주소가 2^N 에 배수가 되도록 align을 해준다.

컴파일할 때 저 옵션을 주지 않으면 -mpreferred-stack-boundary=4가 디폴트로 들어가게 된다. 그래서 스택 align이 16바이트로 맞춰지게 된다.

관련 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 /* Validate -mpreferred-stack-boundary= value or default it to
PREFERRED_STACK_BOUNDARY_DEFAULT. */
ix86_preferred_stack_boundary = PREFERRED_STACK_BOUNDARY_DEFAULT;
if (opts_set->x_ix86_preferred_stack_boundary_arg)
{
int min = TARGET_64BIT_P (opts->x_ix86_isa_flags)? 3 : 2;
int max = TARGET_SEH ? 4 : 12;

if (opts->x_ix86_preferred_stack_boundary_arg < min
|| opts->x_ix86_preferred_stack_boundary_arg > max)
{
if (min == max)
error ("%<-mpreferred-stack-boundary%> is not supported "
"for this target");
else
error ("%<-mpreferred-stack-boundary=%d%> is not between %d and %d",
opts->x_ix86_preferred_stack_boundary_arg, min, max);
}
else
ix86_preferred_stack_boundary
= (1 << opts->x_ix86_preferred_stack_boundary_arg) * BITS_PER_UNIT;
}

(Default) Stack aligned to a 16-byte boundary.

disassemble main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0x080491f1 <+0>:	lea    ecx,[esp+0x4]
0x080491f5 <+4>: and esp,0xfffffff0
0x080491f8 <+7>: push DWORD PTR [ecx-0x4]
0x080491fb <+10>: push ebp
0x080491fc <+11>: mov ebp,esp
0x080491fe <+13>: push ecx
0x080491ff <+14>: sub esp,0x44
0x08049202 <+17>: sub esp,0x4
0x08049205 <+20>: push 0x40
0x08049207 <+22>: push 0x0
0x08049209 <+24>: lea eax,[ebp-0x48]
0x0804920c <+27>: push eax
0x0804920d <+28>: call 0x8049080 <memset@plt>
...
0x08049255 <+100>: mov ecx,DWORD PTR [ebp-0x4]
0x08049258 <+103>: leave
0x08049259 <+104>: lea esp,[ecx-0x4]
0x0804925c <+107>: ret

1. 프롤로그 전 (1)

1
2
3
4
5
6
0x080491f1 <+0> :	lea    ecx,[esp+0x4]
0x080491f5 <+4> : and esp,0xfffffff0
0x080491f8 <+7> : push DWORD PTR [ecx-0x4]
0x080491fb <+10>: push ebp
0x080491fc <+11>: mov ebp,esp
0x080491fe <+13>: push ecx

실행하고 난 후.

1
2
$ecx  : 0xffffd960
$esp : 0xffffd95c

ecx 레지스터를 사용하려고 하는걸까?

2. 프롤로그 전 (2)

1
2
3
4
5
6
   0x080491f1 <+0> :	lea    ecx,[esp+0x4]
0x080491f5 <+4> : and esp,0xfffffff0
0x080491f8 <+7> : push DWORD PTR [ecx-0x4]
0x080491fb <+10>: push ebp
0x080491fc <+11>: mov ebp,esp
0x080491fe <+13>: push ecx

실행하고 난 후.

1
2
$ecx  : 0xffffd960
$esp : 0xffffd950
1
2
3
4
5
6
7
8
9
10
0xffffd950│+0x0000: 0x00000000	← $esp
0xffffd954│+0x0004: 0x00000000
0xffffd958│+0x0008: 0x8048354 → "__libc_start_main"
0xffffd95c│+0x000c: 0xf7c1f119 → add esp, 0x10
0xffffd960│+0x0010: 0x00000001
0xffffd964│+0x0014: 0xffffda14 → 0xffffdb90 → "/home/jir4vvit/study/rop32/chall"
0xffffd968│+0x0018: 0xffffda1c → 0xffffdbb1 → "SHELL=/bin/bash"
0xffffd96c│+0x001c: 0xffffd980 → 0xf7e1fe34 → 0x0021fd4c
0xffffd970│+0x0020: 0xf7e1fe34 → 0x0021fd4c
0xffffd974│+0x0024: 0x80491f1 → <main+0> lea ecx, [esp+0x4]

32bit 프로그램에선 block 단위가 4바이트이기 때문에 컴파일러(gcc)가 and 연산으로 esp를 16에 align시키는 모습을 확인할 수 있다.

ecx 레지스터를 왜 push 했을까?

ecx is a temporary register so it has to be saved. Also, depending on optimization level, some of the frame linkage ops that don’t seem strictly necessary to run the program might well be important in order to set up a trace-ready chain of frames.

3. 프롤로그

1
2
3
0x080491fb <+10>:	push   ebp
0x080491fc <+11>: mov ebp,esp
0x080491fe <+13>: push ecx

ebp를 push하고 mov 명령으로 ebp를 esp와 같게 옮겨주었다.

에필로그를 진행해서 ebp와 esp를 애써 맞춰주었음에도 불구하고(?) ecx를 push했다. 32bit 프로그램이라면 무조건 buf, ebp(sfp) 순일 줄 알았는데 중간에 ecx가 끼어있다. 이것을 보지 못하고 무작정 익스를 진행하려고 하다가 평소와 스택이 쌓이는 것이 이상함을 감지하고 분석을 진행하게 되었다.

4. memset 함수 호출

에필로그 진행 후 함수를 호출할 때 스택에 인자들이 push되는 것을 살펴보자.

분석할 어셈은 아래와 같다.

1
2
3
4
5
6
7
8
   0x080491fe <+13>:	push   ecx  
0x080491ff <+14>: sub esp,0x44
0x08049202 <+17>: sub esp,0x4
0x08049205 <+20>: push 0x40
0x08049207 <+22>: push 0x0
0x08049209 <+24>: lea eax,[ebp-0x48]
0x0804920c <+27>: push eax
0x0804920d <+28>: call 0x8049080 <memset@plt>
1
2
3
4
5
6
memset@plt (
[sp + 0x0] = 0xffffd9000x00000000,
[sp + 0x4] = 0x000000,
[sp + 0x8] = 0x000040,
[sp + 0xc] = 0x000000
)

실행하고 난 후.

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
0xffffd8f0│+0x0000: 0xffffd900  →  0x00000000	← $esp
0xffffd8f4│+0x0004: 0x00000000
0xffffd8f8│+0x0008: 0x000040 ("@"?)
0xffffd8fc│+0x000c: 0x00000000
0xffffd900│+0x0010: 0x00000000 //<-- 여기서부터 0x40 바이트 초기화
0xffffd904│+0x0014: 0x00000000
0xffffd908│+0x0018: 0x00000000
0xffffd90c│+0x001c: 0x00000000
0xffffd910│+0x0020: 0x00000000
0xffffd914│+0x0024: 0x00000000

0xffffd918│+0x0028: 0x00000000
0xffffd91c│+0x002c: 0x00000000
0xffffd920│+0x0030: 0x00000000
0xffffd924│+0x0034: 0x00000000
0xffffd928│+0x0038: 0x00000000
0xffffd92c│+0x003c: 0x00000000
0xffffd930│+0x0040: 0x00000000
0xffffd934│+0x0044: 0x00000000
0xffffd938│+0x0048: 0x00000000
0xffffd93c│+0x004c: 0x00000000 //<-- 0xffffd900 ~ 0xffffd93f 까지 0x40 바이트가 초기화 됨

0xffffd940│+0x0050: 0x00000000
0xffffd944│+0x0054: 0xffffd960 → 0x00000001 //← $ecx
0xffffd948│+0x0058: 0x00000000 ← $ebp
0xffffd94c│+0x005c: 0xf7c1f119 → add esp, 0x10
0xffffd950│+0x0060: 0x00000000
0xffffd954│+0x0064: 0x00000000
0xffffd958│+0x0068: 0x8048354 → "__libc_start_main"
0xffffd95c│+0x006c: 0xf7c1f119 → add esp, 0x10
0xffffd960│+0x0070: 0x00000001
0xffffd964│+0x0074: 0xffffda14 → 0xffffdb90 → "/home/jir4vvit/study/rop32/chall"

ecx를 push했기 때문에 align이 맞지 않아 buf 크기는 0x40이지만 esp를 0x44만큼 올려주었다.(buf를 위해 공간확보)

또한 곧바로 esp를 0x4 올려주었는데, 이는 memset의 인자를 구성할 때 align을 맞춰주기 위해서이다.

이 후에 실질적으로 memset에서 사용하는 세 개의 인자를 push했다.

5. 에필로그

에필로그에 낯선 명령이 추가되어 있다.

1
2
3
4
0x08049255 <+100>:	mov    ecx,DWORD PTR [ebp-0x4]
0x08049258 <+103>: leave
0x08049259 <+104>: lea esp,[ecx-0x4]
0x0804925c <+107>: ret

무작정 익스를 진행하려고 할 때 main+100에서 터졌었다. 스택 상황이 무조건 buf, ebp(sfp) 순서임을 기대했기 때문이다.
실제론 buf, ecx(ebp-0x4), ebp(sfp) 순이다.

ebp 위치라고 생각하고 넣었던 값이 실제론 ebp-0x4였기 때문에 터져버렸다.

(For 32bit ROP in Pwnable) Stack aligned to a 4-byte boundary.

32bit 프로그램에선 block 단위가 4바이트이다. 따라서 스택 구조는 통상적으로 알고 있는 그 구조고, 프롤로그와 에필로그도 알고 있는 그 어셈이다. 자세히 설명하지 않고 코드와 스택 구조만 보여주고 넘어가겠다.

참고로 Pwnable에서의 32bit 프로그램은 (거의) 대부분 -mpreferred-stack-boundary=2 옵션으로 컴파일되었다.

disassemble main

1
2
3
4
5
6
7
8
9
10
11
0x080491ee <+0>:	push   ebp
0x080491ef <+1>: mov ebp,esp
0x080491f1 <+3>: sub esp,0x40
0x080491f4 <+6>: push 0x40
0x080491f6 <+8>: push 0x0
0x080491f8 <+10>: lea eax,[ebp-0x40]
0x080491fb <+13>: push eax
0x080491fc <+14>: call 0x8049080 <memset@plt>
...
0x0804923b <+77>: leave
0x0804923c <+78>: ret

memset 함수 호출

1
2
3
4
5
6
memset@plt (
[sp + 0x0] = 0xffffd9080x00000000,
[sp + 0x4] = 0x000000,
[sp + 0x8] = 0x000040,
[sp + 0xc] = 0x000000
)
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
0xffffd8fc│+0x0000: 0xffffd908  →  0x00000000	← $esp
0xffffd900│+0x0004: 0x00000000
0xffffd904│+0x0008: 0x000040 ("@"?)
0xffffd908│+0x000c: 0x00000000 //<-- 여기서부터 0x40바이트 초기화
0xffffd90c│+0x0010: 0x00000000
0xffffd910│+0x0014: 0x00000000
0xffffd914│+0x0018: 0x00000000
0xffffd918│+0x001c: 0x00000000
0xffffd91c│+0x0020: 0x00000000
0xffffd920│+0x0024: 0x00000000

0xffffd924│+0x0028: 0x00000000
0xffffd928│+0x002c: 0x00000000
0xffffd92c│+0x0030: 0x00000000
0xffffd930│+0x0034: 0x00000000
0xffffd934│+0x0038: 0x00000000
0xffffd938│+0x003c: 0x00000000
0xffffd93c│+0x0040: 0x00000000
0xffffd940│+0x0044: 0x00000000
0xffffd944│+0x0048: 0x00000000 //<-- 0xffffd908 ~ 0xffffd947 까지 0x40 바이트가 초기화 됨
0xffffd948│+0x004c: 0x00000000 ← $ebp

0xffffd94c│+0x0050: 0xf7c1f119 → add esp, 0x10
0xffffd950│+0x0054: 0x00000001
0xffffd954│+0x0058: 0xffffda04 → 0xffffdb8e → "/home/jir4vvit/study/rop32/challr"
0xffffd958│+0x005c: 0xffffda0c → 0xffffdbb0 → "SHELL=/bin/bash"
0xffffd95c│+0x0060: 0xffffd970 → 0xf7e1fe34 → 0x0021fd4c
0xffffd960│+0x0064: 0xf7e1fe34 → 0x0021fd4c
0xffffd964│+0x0068: 0x80491ee → <main+0> push ebp
0xffffd968│+0x006c: 0x00000001
0xffffd96c│+0x0070: 0xffffda04 → 0xffffdb8e → "/home/jir4vvit/study/rop32/challr"
0xffffd970│+0x0074: 0xf7e1fe34 → 0x0021fd4c

More

참고로 64bit 프로그램에서는 block 단위가 4바이트에서 8바이트로 늘어났기 때문에 -mpreferred-stack-boundary=2 옵션을 사용하려고 하면 에러를 내뿜는다.

궁금증

무려 6년 9개월 전 어떤 분이 하신 궁금증과 동일한 궁금증을 가졌다. 안타깝게도 해당 질문에 대한 답은 찾아보기 어려웠다.

I noticed that when not using -mpreferred-stack-boundary=2, gcc might compile “main” with an interesting prologue/epilogue: effectively “relying on $ecx (which relies on $ebp-4) for $esp value before ret”. Has anyone else come across this observation?

This means you can not overwrite normal ret address staying at $ebp+4, but instead you have to overwrite $ebp-4 (that is ecx) and reposition the stack pointer and your return address (effectively using a stack pivot) to further the exploitation.