리눅스 커널 익스플로잇을 공부해보고 싶어서 리눅스 커널 개론을 공부해보았다.
리눅스 커널은 리눅스 운영체제의 핵심 부분으로 하드웨어와 사용자 프로그램 사이를 연결해주는 역할을 한다.

리눅스의 커널은 단일형 커널 구조를 가지며, 모듈화되어 있어 컴파일 후에 모듈을 로드시킬 수 있다.
리눅스 커널의 버전은 다음과 같이 구분된다.
[주 버전].[부 버전].[개정판 번호]
ex) 7.0.6
리눅스 커널의 버전은 커널의 기능 및 수정 사항을 반영하여 구성되며 각 버전은 리눅스의 커널 특정 상태를 나타내고 개발자가 해당 버전의 커널이 어떤 특징을 가지고 있는지 구분할 수 있는 정보를 제공한다.
주 버전은 보통 아키텍쳐 변경, 부 버전은 메이저 버전 내에서의 중요한 변경, 개정판 번호는 버그 수정 등의 사소한 변경을 의미하지만 사실 리누스 토르발스가 맘대로 업데이트한다.
리눅스 커널은 시스템 아키텍쳐마다 그 동작 방식이 조금씩 달라지기 때문에 리눅스 커널 소스의 arch 디렉터리에는 각 아키텍쳐의 특정 코드가 포함되어 있다. 리눅스 커널은 다양한 컴퓨터 아키텍쳐를 지원하기 때문에 따로 관리할 필요가 있다.
LTS(Long Term Support)는 장기간의 지원을 제공하는 커널 버전으로 2년에서 6년가량의 기간 동안 보안 업데이트, 버그 수정 등의 패치가 제공된다.
Stable 커널은 새로운 커널 기능이 빠르게 추가되는 커널로 최신 하드웨어 지원 및 새로운 네트워크 기능이 필요한 시스템에서 사용한다. LTS보다 지원 기간이 짧다.
리눅스에서는 메모리를 Kernel Space와 User Space로 구분 지어 사용한다. 이러한 구분 없이 일반 유저가 시스템의 모든 자원을 제한 없이 사용할 수 있다면 다른 프로세스의 메모리에 접근하여 조작하는 등 시스템의 안정성이 무너질 수 있기 때문에 두 영역의 구분은 필수적이다.
일반 유저는 시스템의 제약 조건에 따라 커널에 시스템 콜을 해야한다.
x86-64 아키텍쳐에선 다음과 같이 syscall 명령어를 통해 요청을 수행한다.
mov rax, 3 // 시스템 콜 번호 3 (sys_close)
mov rdi, 4 // 전달된 첫 번째 인자 (fd)
syscall // 시스템 콜을 통해 커널 영역으로 진입
syscall이 호출되면 CPU는 커널 모드로 전환되고 syscall의 시작점인 entry_SYSCALL_64() 함수에 진입한다. 이후 sys_call_table로부터 유저가 요청한 시스템 콜을 호출하게 된다.

리눅스의 응용 프로그램은 fork()를 호출해서 프로세스를 생성하고 pthread_create()를 호출해서 스레드를 생성할 수 있다. 프로세스는 부모와 독립된 메모리 영역을 가지고 있고 스레드는 스택을 제외한 메모리 영역을 공유한다. 즉 응용 프로그램 입장에선 프로세스와 스레드를 구분해서 사용한다.
그러나 커널은 프로세스와 스레드를 구분하지 않고 걍 태스크로 정의한다.
만약 응용 프로그램이 fork() 또는 pthread_create()를 호출해서 프로세스와 스레드 생성을 요청하며 커널은 그냥 copy_process() 함수를 호출한다.

태스크가 생성되면 각 태스크마다 하나씩 task_struct 구조체가 할당된다.
내가 사용하는 Ubuntu24.04.02 LTS의 6.17.0-23-generic 커널의 task_struct 구조체이다.
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
struct thread_info thread_info;
#endif
unsigned int __state;
unsigned int saved_state;
randomized_struct_fields_start
void *stack;
refcount_t usage;
unsigned int flags;
unsigned int ptrace;
int on_cpu;
int on_rq;
int prio;
int static_prio;
int normal_prio;
unsigned int rt_priority;
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
const struct sched_class *sched_class;
unsigned int policy;
int nr_cpus_allowed;
const cpumask_t *cpus_ptr;
cpumask_t *user_cpus_ptr;
cpumask_t cpus_mask;
struct list_head tasks;
struct mm_struct *mm;
struct mm_struct *active_mm;
int exit_state;
int exit_code;
int exit_signal;
pid_t pid;
pid_t tgid;
struct task_struct __rcu *real_parent;
struct task_struct __rcu *parent;
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
struct pid *thread_pid;
const struct cred __rcu *real_cred;
const struct cred __rcu *cred;
char comm[TASK_COMM_LEN];
...
};
주요 필드들은 다음과 같다.
pid : 커널 내부에서 보는 Task ID, 보통 스레드 ID이다.tgid : thread group ID로 유저 영역에서 흔히 보는 프로세스 PID에 가깝다.comm : 프로세스 이름을 저장하는 16바이트 문자열이다.__state : 태스크가 실행 중인지, 대기 중인지 같은 현재 상태를 나타낸다.flags : 커널 스레드 여부 등 태스크의 여러 속성을 비트 플래그로 저장한다.mm : 유저 프로세스의 가상 메모리 공간 정보를 가리킨다.active_mm : 현재 실제로 사용 중인 메모리 주소 공간을 가리킨다.cred : 현재 권한 검사에 사용되는 UID, GID, capability 정보를 가리킨다.real_cred : 원래의 실제 권한 정보를 가리킨다.tasks : 전체 프로세스 리스트에서 이 태스크를 연결하는 리스트 노드이다.*signal : 시그널을 처리하기 위한 정보를 가지고 있는 구조체 signal_struct의 포인터*files : open()한 파일들을 관리하는 구조체 files_struct의 포인터이 task_struct 구조체는 프로세스 스케쥴링 및 메모리 할당 등 커널의 거의 모든 부분에서 사용되기 때문에 중요하다.
current 매크로는 CPU에서 현재 실행 중인 태스크의 task_struct 구조체를 가리키는 포인터이다. 이 매크로를 통해 커널 코드 어디에서나 현재 실행 중인 태스크의 정보에 접근할 수 있어 다양한 커널 기능의 구현에 필수적이다.
예를 들어 네트워크와 관련된 커널 코드에서 현재 실행중인 태스크가 속한 네트워크 네임스페이스를 가져오는 경우에 current를 사용할 수 있다.
static void *net_grab_current_ns(void)
{
struct net *ns = current->nsproxy->net_ns; // current를 사용하여 net ns를 가져옴
#ifdef CONFIG_NET_NS
if (ns)
refcount_inc(&ns->passive);
#endif
return ns;
}
Linux Namespace는 리눅스 커널의 기능 중 하나로 운영 체제 수준에서 리소스의 격리를 제공하는 방법이다.
네임스페이스를 사용하면 하나의 시스템에서 실행되는 프로세스 그룹이 다른 프로세스 그룹과 독립적으로 리소스를 관리하고 볼 수 있다.

위 사진은 유저 영역의 메모리 구조이다.
유저 영역의 메모리 구조는 task_struct의 멤버 mm과 관련이 있다.
struct mm_struct {
struct {
/*
* Fields which are often written to are placed in a separate
* cache line.
*/
...
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
...
} __randomize_layout;
/*
* The mm_cpumask needs to be at the end of mm_struct, because it
* is dynamically sized based on nr_cpu_ids.
*/
unsigned long cpu_bitmap[];
};
mm_struct 구조체를 보면 위와 같다.
여기서 start_code와 end_code는 코드 세그먼트의 시작과 끝의 주소를 담고 있다.
start_data와 end_data는 마찬가지로 데이터 세그먼트의 시작과 끝, start_stack은 스택의 시작 주소, start_brk와 brk 는 힙 세그먼트의 시작과 끝 주소를 담고 있다.
왜 힙은 start_heap이 아니라 brk 인가 싶었는데 brk는 힙 영역의 현재와 끝 지점을 가리키는 포인터이고 동적 할당이 발생하면 brk를 움직여 힙 크기를 늘린다고 한다. 따라서 brk는 증가할 수 있다.


32bit x86 Linux의 가상 메모리 맵
유저 영역의 메모리 구조는 각 프로세스마다 독립적이다. 그렇게 때문에 다른 프로세스가 같은 가상 주소에 접근하더라도 서로 다른 독립적인 데이터를 읽어오게 된다. 그러나 커널 영역의 메모리 구조는 유저 영역과 다르게 모든 프로세스가 같이 공유하기 때문에 서로 다른 프로세스가 같은 커널 영역의 주소에 접근하게 되면 모두 같은 커널 영역 메모리에 접근하게 된다.
========================================================================================================================
Start addr | Offset | End addr | Size | VM area description
========================================================================================================================
| | | |
0000000000000000 | 0 | 00007fffffffffff | 128 TB | user-space virtual memory, different per mm
__________________|____________|__________________|_________|___________________________________________________________
|
| Kernel-space virtual memory, shared between all processes:
____________________________________________________________|___________________________________________________________
| | | |
ffff888000000000 | -119.5 TB | ffffc87fffffffff | 64 TB | direct mapping of all physical memory (page_offset_base)
ffffc90000000000 | -55 TB | ffffe8ffffffffff | 32 TB | vmalloc/ioremap space (vmalloc_base)
__________________|____________|__________________|_________|____________________________________________________________
|
| Identical layout to the 56-bit one from here on:
____________________________________________________________|____________________________________________________________
ffffffff80000000 | -2 GB | ffffffff9fffffff | 512 MB | kernel text mapping, mapped to physical address 0
ffffffffa0000000 |-1536 MB | fffffffffeffffff | 1520 MB | module mapping space
__________________|____________|__________________|_________|___________________________________________________________
0x0000000000000000 ~ 0x00007fffffffffff: 유저 영역이 위치한 가상 주소. 코드, 스택 세그먼트 등이 이 영역에 위치하며 각 프로세스는 독립적인 영역 보유0xffff880000000000 ~ 0xffffc87fffffffff: 물리적인 RAM을 나타내는 물리 메모리 영역0xffffc90000000000 ~ 0xffffe8ffffffffff: vmalloc 영역0xffffffff80000000 ~ 0xffffffff9fffffff: 커널 코드가 위치한 .text 영역. 커널 이미지 위치0xffffffffa0000000 ~ 0xfffffffffeffffff: 커널 모듈이 위치한 영역위 메모리 맵은 x86-64 아키텍쳐의 4-level 페이지 테이블에 해당하는 메모리 맵이다.
가상 주소는 커널이 프로세스에게 제공하는 주소 공간이다. 이 공간은 자신만의 가상 주소 공간을 가지고 실제 물리 메모리의 크기와 독립적이다. 이를 통해 하나의 프로세스는 자신이 전체 메모리를 독점하고 있는 것처럼 동작할 수 있고 다른 프로세스의 메모리 영역에 접근할 수 없다.
가상 주소는 메모리 관리 장치 (MMU)에 의해 물리 주소로 변환된다.
물리 주소는 실제 메모리 하드웨어의 주소를 나타낸다. 물리 주소 공간은 RAM의 실제 메모리에 직접 매핑된다. 커널과 하드웨어는 물리 주소를 사용하여 메모리에 접근하고 데이터를 읽고 쓰는 작업을 수행한다.
리눅스 커널의 슬랩 할당자(SLAB Allocator)는 커널의 동적 메모리 할당 관리를 위해 고안된 시스템으로 효율적인 메모리 사용과 빠른 할당 및 해제를 목적으로 한다. 커널 개발자는 kmalloc()을 호출하여 슬랩 할당자로부터 동적 할당을 받을 수 있고 kfree()를 호출해 해제할 수 있다.

슬랩 할당자는 과거 SLAB, SLUB, SLOB 할당자로 나뉘었다. 그러나 SLUB 할당자의 발전으로 다른 할당자를 충분히 대체할 수 있게 되어서 코드 파편화를 막기 위해 SLOB와 SLAB은 최신 커널에서 제거되었다. 최신 커널 기준으로 슬랩 할당자는 SLUB을 의미하며 다른 할당자는 일반적으론 사용되지 않는다.
슬랩 할당자의 주요 구성 요소는 다음과 같다.
task_struct나 mm_struct 등을 kmalloc()으로 할당받을 때 반환되는 객체로 슬랩 할당자는 할당을 효율적으로 관리하기 위해 여러 조건에 따라 정의된 크기의 슬랩 객체를 미리 할당한다.버디 할당자는 페이지 프레임 단위(4KiB)로 메모리를 할당해주는 할당자로, 보통 큰 메모리 블록을 관리하기 위한 할당자이다.
슬랩 캐시는 슬랩 객체의 크기별로 캐시가 분리되어 있다.

kmalloc-N 캐시는 다음과 같이 GFP_KERNEL 플래그로 kmalloc()을 호출했을 때 사용되는 캐시들이다.
#include <linux/slab.h>
void *alloc = kmalloc(300, GFP_KERNEL);
즉 위와 같이 300바이트의 할당을 요청하면 요청한 크기에 해당하는 kmalloc-512 캐시로부터 객체가 할당된다. 만약 150바이트를 요청하면 kmalloc-192 캐시를, 20바이트를 요청하면 kmalloc-32 캐시를 사용한다.