챕터 3:
프로그램 실행 방법 GitHub에서 수정

지금까지 우리는 CPU가 실행 파일에서 로드된 기계어를 어떻게 실행하는지, 링 기반 보안이 무엇인지, 시스템 콜이 어떻게 작동하는지를 다뤘습니다. 이번 섹션에서는 Linux 커널을 깊이 파고들어 프로그램이 처음부터 어떻게 로드되고 실행되는지 알아보겠습니다.

특별히 x86-64에서의 Linux를 살펴볼 것입니다. 왜일까요?

우리가 배울 대부분의 내용은 비록 다양한 특정 방식에서 차이가 있더라도 다른 운영 체제와 아키텍처에 잘 일반화될 것입니다.

Exec 시스템 콜의 기본 동작

exec 시스템 콜을 보여주는 순서도. 왼쪽에는 "사용자 공간"으로 레이블된 순서도 항목 그룹이 있고, 오른쪽에는 "커널 공간"으로 레이블된 그룹이 있습니다. 사용자 공간 그룹에서 시작: 사용자가 터미널에서 ./file.bin을 실행하면 execve("./file.bin", ...)를 실행합니다. 이것은 SYSCALL 명령어가 실행되는 것으로 흐르고, 그 다음 커널 공간 그룹의 첫 번째 항목을 가리킵니다: "바이너리 로드 및 설정"은 "binfmt 시도"를 가리킵니다. binfmt가 지원되면 새 프로세스를 시작합니다(현재 프로세스를 대체). 그렇지 않으면 binfmt를 다시 시도합니다.

매우 중요한 시스템 콜인 execve부터 시작하겠습니다. 이것은 프로그램을 로드하고, 성공하면 현재 프로세스를 해당 프로그램으로 교체합니다. 몇 가지 다른 시스템 콜(execlp, execvpe 등)이 존재하지만, 모두 다양한 방식으로 execve 위에 계층화되어 있습니다.

참고: execveat

execve실제로는 execveat 위에 구축되어 있습니다. execveat은 일부 구성 옵션과 함께 프로그램을 실행하는 보다 일반적인 시스템 콜입니다. 간단함을 위해 대부분 execve에 대해 이야기하겠습니다; 유일한 차이점은 execveat에 몇 가지 기본값을 제공한다는 것입니다.

ve가 무엇을 의미하는지 궁금하신가요? v는 한 매개변수가 인수의 벡터(목록)(argv)임을 의미하고, e는 다른 매개변수가 환경 변수의 벡터(envp)임을 의미합니다. 다른 다양한 exec 시스템 콜은 다른 호출 서명을 지정하기 위해 다른 접미사를 갖습니다. execveatat은 단지 “at”이며, execve를 실행할 위치를 지정하기 때문입니다.

execve의 호출 서명은 다음과 같습니다:

int execve(const char *filename, char *const argv[], char *const envp[]);

재미있는 사실! 프로그램의 첫 번째 인수가 프로그램의 이름이라는 그 관례를 아시나요? 그것은 순전히 관례이며, 실제로 execve 시스템 콜 자체에 의해 설정되지 않습니다! 첫 번째 인수는 argv 인수의 첫 번째 항목으로 execve에 전달되는 모든 것이 될 것이며, 프로그램 이름과 아무 관련이 없더라도 마찬가지입니다.

흥미롭게도, execveargv[0]이 프로그램 이름이라고 가정하는 일부 코드를 가지고 있습니다. 해석된 스크립팅 언어에 대해 이야기할 때 이에 대해 더 자세히 설명하겠습니다.

단계 0: 정의

우리는 이미 시스템 콜이 어떻게 작동하는지 알고 있지만, 실제 코드 예제는 본 적이 없습니다! Linux 커널의 소스 코드를 살펴보고 execve가 내부적으로 어떻게 정의되는지 봅시다:

fs/exec.c
SYSCALL_DEFINE3(execve,
		const char __user *, filename,
		const char __user *const __user *, argv,
		const char __user *const __user *, envp)
{
	return do_execve(getname(filename), argv, envp);
}

SYSCALL_DEFINE3는 3개 인수 시스템 콜의 코드를 정의하기 위한 매크로입니다.

저는 왜 arity가 매크로 이름에 하드코딩되어 있는지 궁금했습니다; 검색해 보니 이것이 일부 보안 취약점을 수정하기 위한 해결 방법이었다는 것을 알게 되었습니다.

filename 인수는 getname() 함수에 전달되며, 이 함수는 사용자 공간에서 커널 공간으로 문자열을 복사하고 일부 사용량 추적 작업을 수행합니다. include/linux/fs.h에 정의된 filename 구조체를 반환합니다. 이것은 사용자 공간의 원래 문자열에 대한 포인터와 커널 공간에 복사된 값에 대한 새 포인터를 저장합니다:

include/linux/fs.h
struct filename {
	const char		*name;	/* pointer to actual string */
	const __user char	*uptr;	/* original userland pointer */
	int			refcnt;
	struct audit_names	*aname;
	const char		iname[];
};

그런 다음 execve 시스템 콜은 do_execve() 함수를 호출합니다. 이것은 차례로 일부 기본값과 함께 do_execveat_common()을 호출합니다. 앞서 언급한 execveat 시스템 콜도 do_execveat_common()을 호출하지만, 더 많은 사용자 제공 옵션을 전달합니다.

아래 스니펫에서, 나는 do_execvedo_execveat의 정의를 모두 포함했습니다:

fs/exec.c
static int do_execve(struct filename *filename,
	const char __user *const __user *__argv,
	const char __user *const __user *__envp)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };
	return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

static int do_execveat(int fd, struct filename *filename,
		const char __user *const __user *__argv,
		const char __user *const __user *__envp,
		int flags)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };

	return do_execveat_common(fd, filename, argv, envp, flags);
}

[spacing sic]

execveat에서, 파일 디스크립터(어떤 리소스를 가리키는 일종의 id)가 시스템 콜에 전달된 다음 do_execveat_common에 전달됩니다. 이것은 프로그램을 실행할 디렉토리를 상대적으로 지정합니다.

execve에서는 파일 디스크립터 인수에 특수 값인 AT_FDCWD가 사용됩니다. 이것은 Linux 커널의 공유 상수로, 함수에 경로 이름을 현재 작업 디렉토리에 상대적인 것으로 해석하도록 지시합니다. 파일 디스크립터를 허용하는 함수는 일반적으로 if (fd == AT_FDCWD) { /* special codepath */ }와 같은 수동 검사를 포함합니다.

단계 1: 설정

우리는 이제 프로그램 실행을 처리하는 핵심 함수인 do_execveat_common에 도달했습니다. 이 함수가 무엇을 하는지에 대한 더 큰 그림을 보기 위해 코드를 응시하는 것에서 잠깐 물러나겠습니다.

do_execveat_common의 첫 번째 주요 작업은 linux_binprm이라는 구조체를 설정하는 것입니다. 전체 구조체 정의의 복사본을 포함하지는 않겠지만, 살펴볼 몇 가지 중요한 필드가 있습니다:

(TIL: binprm은 binary program을 의미합니다.)

이 버퍼 buf를 더 자세히 살펴봅시다:

linux_binprm @ include/linux/binfmts.h
	char buf[BINPRM_BUF_SIZE];

보시다시피, 그 길이는 상수 BINPRM_BUF_SIZE로 정의됩니다. 코드베이스에서 이 문자열을 검색하면, include/uapi/linux/binfmts.h에서 이에 대한 정의를 찾을 수 있습니다:

include/uapi/linux/binfmts.h
/* sizeof(linux_binprm->buf) */
#define BINPRM_BUF_SIZE 256

따라서 커널은 실행된 파일의 처음 256바이트를 이 메모리 버퍼에 로드합니다.

참고: UAPI가 뭔가요?

위 코드의 경로에 /uapi/가 포함되어 있음을 알 수 있습니다. 왜 길이가 linux_binprm 구조체와 같은 파일인 include/linux/binfmts.h에 정의되지 않았을까요?

UAPI는 “userspace API”를 의미합니다. 이 경우, 누군가가 버퍼의 길이가 커널의 공개 API의 일부여야 한다고 결정했음을 의미합니다. 이론적으로 모든 UAPI는 사용자 공간에 노출되고, 모든 비-UAPI는 커널 코드에 비공개입니다.

커널과 사용자 공간 코드는 원래 하나의 뒤죽박죽 덩어리로 공존했습니다. 2012년에 UAPI 코드는 유지 관리성을 개선하기 위한 시도로 별도의 디렉토리로 리팩토링되었습니다.

단계 2: Binfmts

커널의 다음 주요 작업은 여러 “binfmt”(바이너리 형식) 핸들러를 반복하는 것입니다. 이러한 핸들러는 fs/binfmt_elf.cfs/binfmt_flat.c와 같은 파일에 정의되어 있습니다. 커널 모듈도 자체 binfmt 핸들러를 풀에 추가할 수 있습니다.

각 핸들러는 linux_binprm 구조체를 받아 핸들러가 프로그램의 형식을 이해하는지 확인하는 load_binary() 함수를 노출합니다.

이것은 종종 버퍼에서 매직 넘버를 찾거나, (또한 버퍼에서) 프로그램의 시작을 디코딩하려고 시도하거나, 파일 확장자를 확인하는 것을 포함합니다. 핸들러가 형식을 지원하면 실행을 위해 프로그램을 준비하고 성공 코드를 반환합니다. 그렇지 않으면 일찍 종료하고 오류 코드를 반환합니다.

커널은 성공하는 것에 도달할 때까지 각 binfmt의 load_binary() 함수를 시도합니다. 때때로 이것들은 재귀적으로 실행됩니다; 예를 들어, 스크립트에 인터프리터가 지정되어 있고 그 인터프리터가 그 자체로 스크립트인 경우, 계층 구조는 binfmt_script > binfmt_script > binfmt_elf일 수 있습니다 (여기서 ELF는 체인의 끝에 있는 실행 가능한 형식입니다).

형식 하이라이트: 스크립트

Linux가 지원하는 많은 형식 중에서 binfmt_script는 제가 특별히 이야기하고 싶은 첫 번째 형식입니다.

shebang을 읽거나 쓴 적이 있나요? 인터프리터의 경로를 지정하는 일부 스크립트의 시작 부분에 있는 그 줄 말이죠?

#!/bin/bash

저는 항상 이것들이 셸에 의해 처리된다고 가정했지만, 아닙니다! Shebang은 실제로 커널의 기능이며, 스크립트는 다른 모든 프로그램과 동일한 시스템 콜로 실행됩니다. 컴퓨터는 정말 멋집니다.

fs/binfmt_script.c가 파일이 shebang으로 시작하는지 어떻게 확인하는지 살펴보세요:

load_script @ fs/binfmt_script.c
	/* Not ours to exec if we don't start with "#!". */
	if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
		return -ENOEXEC;

파일이 shebang으로 시작하면, binfmt 핸들러는 인터프리터 경로와 경로 뒤의 공백으로 구분된 인수를 읽습니다. 개행 문자나 버퍼의 끝에 도달하면 멈춥니다.

여기에서 두 가지 흥미롭고 이상한 일이 일어나고 있습니다.

첫째, 파일의 처음 256바이트로 채워진 linux_binprm의 버퍼를 기억하시나요? 그것은 실행 가능한 형식 감지에 사용되지만, 그 동일한 버퍼는 또한 binfmt_script에서 shebang이 읽혀지는 곳입니다.

제 연구 중에, 버퍼를 128바이트 길이로 설명한 글을 읽었습니다. 그 글이 게시된 후 어느 시점에, 길이가 256바이트로 두 배가 되었습니다! 왜인지 궁금해서, Linux 소스 코드에서 BINPRM_BUF_SIZE가 정의된 줄의 Git blame — 특정 코드 줄을 편집한 모든 사람의 로그 — 를 확인했습니다. 놀랍게도…

Visual Studio Code 편집기의 Git blame 창 스크린샷. git blame은 "#define BINPRM_BUF_SIZE 128" 줄이 256으로 변경된 것을 보여줍니다. 커밋은 Oleg Nesterov에 의한 것이며, 주요 텍스트는 "exec: increase BINPRM_BUF_SIZE to 256. Large enterprise clients often run applications out of networked file systems where the IT mandated layout of project volumes can end up leading to paths that are longer than 128 characters.  Bumping this up to the next order of two solves this problem in all but the most egregious case while still fitting into a 512b slab."입니다. 커밋은 Linus Torvalds 등에 의해 서명되었습니다.

컴퓨터는 정말 멋집니다!

Shebang은 커널에 의해 처리되고, 전체 파일을 로드하는 대신 buf에서 가져오기 때문에, 항상 buf의 길이로 잘립니다. 분명히, 4년 전에 누군가가 커널이 >128자 경로를 자르는 것에 짜증이 났고, 그들의 해결책은 버퍼 크기를 두 배로 늘려 절단 지점을 두 배로 늘리는 것이었습니다! 오늘날, 여러분의 Linux 머신에서 256자보다 긴 shebang 줄이 있으면 256자를 넘는 모든 것이 완전히 손실됩니다.

shebang 절단을 보여주는 다이어그램. file.bin이라는 파일의 대용량 바이트 배열. 처음 256바이트는 강조 표시되고 "buf에 로드됨"으로 레이블이 지정되며, 나머지 바이트는 반투명하고 "무시됨, 256바이트 이후"로 레이블이 지정됩니다.

이것 때문에 버그가 있다고 상상해보세요. 코드를 망가뜨리는 것의 근본 원인을 알아내려고 노력한다고 상상해보세요. 문제가 Linux 커널 깊숙한 곳에 있다는 것을 발견했을 때 어떤 기분일지 상상해보세요. 경로의 일부가 신비롭게 사라진 것을 발견하는 대규모 기업의 다음 IT 담당자에게 화가 있을 것입니다.

두 번째 이상한 것: argv[0]이 프로그램 이름이라는 것은 관례일 뿐이며, 호출자가 원하는 argv를 exec 시스템 콜에 전달할 수 있고 그것이 통제 없이 통과할 것이라는 것을 기억하시나요?

binfmt_scriptargv[0]이 프로그램 이름이라고 가정하는 곳 중 하나라는 것이 우연히 발생합니다. 항상 argv[0]을 제거하고, 그런 다음 argv의 시작 부분에 다음을 추가합니다:

예: 인수 수정

샘플 execve 호출을 살펴봅시다:

// Arguments: filename, argv, envp
execve("./script", [ "A", "B", "C" ], []);

이 가상의 script 파일은 첫 번째 줄로 다음 shebang을 가지고 있습니다:

script
#!/usr/bin/node --experimental-module

Node 인터프리터에 최종적으로 전달되는 수정된 argv는 다음과 같습니다:

[ "/usr/bin/node", "--experimental-module", "./script", "B", "C" ]

argv를 업데이트한 후, 핸들러는 linux_binprm.interp를 인터프리터 경로(이 경우 Node 바이너리)로 설정하여 실행을 위한 파일 준비를 마칩니다. 마지막으로, 프로그램 실행 준비 성공을 나타내기 위해 0을 반환합니다.

형식 하이라이트: 기타 인터프리터

또 다른 흥미로운 핸들러는 binfmt_misc입니다. 이것은 /proc/sys/fs/binfmt_misc/에 특수 파일 시스템을 마운트함으로써 사용자 공간 구성을 통해 일부 제한된 형식을 추가할 수 있는 능력을 엽니다. 프로그램은 이 디렉토리의 파일에 특별히 형식화된 쓰기를 수행하여 자체 핸들러를 추가할 수 있습니다. 각 구성 항목은 다음을 지정합니다:

binfmt_misc 시스템은 종종 Java 설치에서 사용되며, 0xCAFEBABE 매직 바이트로 클래스 파일을 감지하고 확장자로 JAR 파일을 감지하도록 구성됩니다. 제 특정 시스템에서는 .pyc 확장자로 Python 바이트코드를 감지하고 적절한 핸들러에 전달하도록 구성된 핸들러가 있습니다.

이것은 프로그램 설치 프로그램이 높은 권한의 커널 코드를 작성할 필요 없이 자체 형식에 대한 지원을 추가할 수 있게 하는 꽤 멋진 방법입니다.

결국에는 (Linkin Park 노래가 아님)

exec 시스템 콜은 항상 두 가지 경로 중 하나로 끝날 것입니다:

Unix 계열 시스템을 사용한 적이 있다면, 터미널에서 실행된 셸 스크립트가 shebang 줄이나 .sh 확장자가 없어도 여전히 실행된다는 것을 알아차렸을 것입니다. 비-Windows 터미널이 있다면 지금 바로 테스트해볼 수 있습니다:

Shell session
$ echo "echo hello" > ./file
$ chmod +x ./file
$ ./file
hello

(chmod +x는 OS에 파일이 실행 가능하다고 알려줍니다. 그렇지 않으면 실행할 수 없습니다.)

그렇다면, 왜 셸 스크립트가 셸 스크립트로 실행될까요? 커널의 형식 핸들러는 식별 가능한 레이블이 없는 셸 스크립트를 감지할 명확한 방법이 없어야 합니다!

글쎄요, 이 동작은 커널의 일부가 아니라는 것이 밝혀졌습니다. 실제로 이 실패 사례를 처리하는 일반적인 방법입니다.

셸을 사용하여 파일을 실행하고 exec 시스템 콜이 실패하면, 대부분의 셸은 첫 번째 인수로 파일 이름을 사용하여 셸을 실행하여 파일을 셸 스크립트로 다시 실행하려고 시도합니다. Bash는 일반적으로 자신을 이 인터프리터로 사용하는 반면, ZSH는 sh가 무엇이든 사용하며, 일반적으로 Bourne shell입니다.

이 동작이 매우 일반적인 이유는 POSIX에 지정되어 있기 때문입니다. POSIX는 Unix 시스템 간에 코드를 이식 가능하게 만들기 위해 설계된 오래된 표준입니다. POSIX는 대부분의 도구나 운영 체제에 의해 엄격하게 따라지지는 않지만, 많은 관례가 여전히 공유됩니다.

[exec 시스템 콜이] [ENOEXEC] 오류와 동등한 오류로 인해 실패하면, 셸은 명령 이름을 첫 번째 피연산자로 하여 셸을 호출한 것과 동등한 명령을 실행해야 합니다, 나머지 인수는 새 셸에 전달됩니다. 실행 가능한 파일이 텍스트 파일이 아니면, 셸은 이 명령 실행을 우회할 수 있습니다. 이 경우 오류 메시지를 작성하고 종료 상태 126을 반환해야 합니다.

출처: Shell Command Language, POSIX.1-2017

컴퓨터는 정말 멋집니다!

챕터 4로 계속: 엘프 로드가 되다