지금까지 우리는 CPU가 실행 파일에서 로드된 기계어를 어떻게 실행하는지, 링 기반 보안이 무엇인지, 시스템 콜이 어떻게 작동하는지를 다뤘습니다. 이번 섹션에서는 Linux 커널을 깊이 파고들어 프로그램이 처음부터 어떻게 로드되고 실행되는지 알아보겠습니다.
특별히 x86-64에서의 Linux를 살펴볼 것입니다. 왜일까요?
Linux는 데스크톱, 모바일, 서버 사용 사례를 위한 완전한 기능을 갖춘 프로덕션 OS입니다. Linux는 오픈 소스이므로 소스 코드를 읽기만 하면 연구하기가 매우 쉽습니다. 이 글에서 커널 코드를 직접 참조할 것입니다!
x86-64는 대부분의 현대 데스크톱 컴퓨터가 사용하는 아키텍처이며, 많은 코드의 타겟 아키텍처입니다. 제가 언급하는 x86-64 특정 동작의 하위 집합은 잘 일반화될 것입니다.
우리가 배울 대부분의 내용은 비록 다양한 특정 방식에서 차이가 있더라도 다른 운영 체제와 아키텍처에 잘 일반화될 것입니다.
Exec 시스템 콜의 기본 동작
매우 중요한 시스템 콜인 execve부터 시작하겠습니다. 이것은 프로그램을 로드하고, 성공하면 현재 프로세스를 해당 프로그램으로 교체합니다. 몇 가지 다른 시스템 콜(execlp, execvpe 등)이 존재하지만, 모두 다양한 방식으로 execve 위에 계층화되어 있습니다.
참고: execveat
execve는 실제로는execveat 위에 구축되어 있습니다. execveat은 일부 구성 옵션과 함께 프로그램을 실행하는 보다 일반적인 시스템 콜입니다. 간단함을 위해 대부분 execve에 대해 이야기하겠습니다; 유일한 차이점은 execveat에 몇 가지 기본값을 제공한다는 것입니다.
ve가 무엇을 의미하는지 궁금하신가요? v는 한 매개변수가 인수의 벡터(목록)(argv)임을 의미하고, e는 다른 매개변수가 환경 변수의 벡터(envp)임을 의미합니다. 다른 다양한 exec 시스템 콜은 다른 호출 서명을 지정하기 위해 다른 접미사를 갖습니다. execveat의 at은 단지 “at”이며, execve를 실행할 위치를 지정하기 때문입니다.
argv는 프로그램에 대한 인수의 널 종료(마지막 항목이 널 포인터임을 의미) 목록입니다. C main 함수에 전달되는 것을 흔히 볼 수 있는 argc 인수는 실제로 나중에 시스템 콜에 의해 계산되므로 널 종료가 필요합니다.
envp 인수는 애플리케이션의 컨텍스트로 사용되는 환경 변수의 또 다른 널 종료 목록을 포함합니다. 그것들은… 관례적으로 KEY=VALUE 쌍입니다. 관례적으로. 컴퓨터가 좋아요.
재미있는 사실! 프로그램의 첫 번째 인수가 프로그램의 이름이라는 그 관례를 아시나요? 그것은 순전히 관례이며, 실제로 execve 시스템 콜 자체에 의해 설정되지 않습니다! 첫 번째 인수는 argv 인수의 첫 번째 항목으로 execve에 전달되는 모든 것이 될 것이며, 프로그램 이름과 아무 관련이 없더라도 마찬가지입니다.
흥미롭게도, execve는 argv[0]이 프로그램 이름이라고 가정하는 일부 코드를 가지고 있습니다. 해석된 스크립팅 언어에 대해 이야기할 때 이에 대해 더 자세히 설명하겠습니다.
단계 0: 정의
우리는 이미 시스템 콜이 어떻게 작동하는지 알고 있지만, 실제 코드 예제는 본 적이 없습니다! Linux 커널의 소스 코드를 살펴보고 execve가 내부적으로 어떻게 정의되는지 봅시다:
저는 왜 arity가 매크로 이름에 하드코딩되어 있는지 궁금했습니다; 검색해 보니 이것이 일부 보안 취약점을 수정하기 위한 해결 방법이었다는 것을 알게 되었습니다.
filename 인수는 getname() 함수에 전달되며, 이 함수는 사용자 공간에서 커널 공간으로 문자열을 복사하고 일부 사용량 추적 작업을 수행합니다. include/linux/fs.h에 정의된 filename 구조체를 반환합니다. 이것은 사용자 공간의 원래 문자열에 대한 포인터와 커널 공간에 복사된 값에 대한 새 포인터를 저장합니다:
struct filename {constchar*name; /* pointer to actual string */const __user char*uptr; /* original userland pointer */int refcnt;struct audit_names *aname;constchar iname[];};
그런 다음 execve 시스템 콜은 do_execve() 함수를 호출합니다. 이것은 차례로 일부 기본값과 함께 do_execveat_common()을 호출합니다. 앞서 언급한 execveat 시스템 콜도 do_execveat_common()을 호출하지만, 더 많은 사용자 제공 옵션을 전달합니다.
아래 스니펫에서, 나는 do_execve와 do_execveat의 정의를 모두 포함했습니다:
execveat에서, 파일 디스크립터(어떤 리소스를 가리키는 일종의 id)가 시스템 콜에 전달된 다음 do_execveat_common에 전달됩니다. 이것은 프로그램을 실행할 디렉토리를 상대적으로 지정합니다.
execve에서는 파일 디스크립터 인수에 특수 값인 AT_FDCWD가 사용됩니다. 이것은 Linux 커널의 공유 상수로, 함수에 경로 이름을 현재 작업 디렉토리에 상대적인 것으로 해석하도록 지시합니다. 파일 디스크립터를 허용하는 함수는 일반적으로 if (fd == AT_FDCWD) { /* special codepath */ }와 같은 수동 검사를 포함합니다.
단계 1: 설정
우리는 이제 프로그램 실행을 처리하는 핵심 함수인 do_execveat_common에 도달했습니다. 이 함수가 무엇을 하는지에 대한 더 큰 그림을 보기 위해 코드를 응시하는 것에서 잠깐 물러나겠습니다.
do_execveat_common의 첫 번째 주요 작업은 linux_binprm이라는 구조체를 설정하는 것입니다. 전체 구조체 정의의 복사본을 포함하지는 않겠지만, 살펴볼 몇 가지 중요한 필드가 있습니다:
mm_struct 및 vm_area_struct와 같은 데이터 구조는 새 프로그램을 위한 가상 메모리 관리를 준비하기 위해 정의됩니다.
argc와 envc는 계산되어 프로그램에 전달되도록 저장됩니다.
filename과 interp는 각각 프로그램의 파일 이름과 인터프리터를 저장합니다. 이것들은 서로 같게 시작하지만 일부 경우에 변경될 수 있습니다: 그러한 경우 중 하나는 shebang이 있는 해석된 스크립트를 실행할 때입니다. 예를 들어, Python 프로그램을 실행할 때 filename은 소스 파일을 가리키지만 interp는 Python 인터프리터의 경로입니다.
buf는 실행할 파일의 처음 256바이트로 채워진 배열입니다. 파일의 형식을 감지하고 스크립트 shebang을 로드하는 데 사용됩니다.
위 코드의 경로에 /uapi/가 포함되어 있음을 알 수 있습니다. 왜 길이가 linux_binprm 구조체와 같은 파일인 include/linux/binfmts.h에 정의되지 않았을까요?
UAPI는 “userspace API”를 의미합니다. 이 경우, 누군가가 버퍼의 길이가 커널의 공개 API의 일부여야 한다고 결정했음을 의미합니다. 이론적으로 모든 UAPI는 사용자 공간에 노출되고, 모든 비-UAPI는 커널 코드에 비공개입니다.
커널과 사용자 공간 코드는 원래 하나의 뒤죽박죽 덩어리로 공존했습니다. 2012년에 UAPI 코드는 유지 관리성을 개선하기 위한 시도로 별도의 디렉토리로 리팩토링되었습니다.
단계 2: Binfmts
커널의 다음 주요 작업은 여러 “binfmt”(바이너리 형식) 핸들러를 반복하는 것입니다. 이러한 핸들러는 fs/binfmt_elf.c 및 fs/binfmt_flat.c와 같은 파일에 정의되어 있습니다. 커널 모듈도 자체 binfmt 핸들러를 풀에 추가할 수 있습니다.
각 핸들러는 linux_binprm 구조체를 받아 핸들러가 프로그램의 형식을 이해하는지 확인하는 load_binary() 함수를 노출합니다.
이것은 종종 버퍼에서 매직 넘버를 찾거나, (또한 버퍼에서) 프로그램의 시작을 디코딩하려고 시도하거나, 파일 확장자를 확인하는 것을 포함합니다. 핸들러가 형식을 지원하면 실행을 위해 프로그램을 준비하고 성공 코드를 반환합니다. 그렇지 않으면 일찍 종료하고 오류 코드를 반환합니다.
커널은 성공하는 것에 도달할 때까지 각 binfmt의 load_binary() 함수를 시도합니다. 때때로 이것들은 재귀적으로 실행됩니다; 예를 들어, 스크립트에 인터프리터가 지정되어 있고 그 인터프리터가 그 자체로 스크립트인 경우, 계층 구조는 binfmt_script > binfmt_script > binfmt_elf일 수 있습니다 (여기서 ELF는 체인의 끝에 있는 실행 가능한 형식입니다).
형식 하이라이트: 스크립트
Linux가 지원하는 많은 형식 중에서 binfmt_script는 제가 특별히 이야기하고 싶은 첫 번째 형식입니다.
shebang을 읽거나 쓴 적이 있나요? 인터프리터의 경로를 지정하는 일부 스크립트의 시작 부분에 있는 그 줄 말이죠?
1
#!/bin/bash
저는 항상 이것들이 셸에 의해 처리된다고 가정했지만, 아닙니다! Shebang은 실제로 커널의 기능이며, 스크립트는 다른 모든 프로그램과 동일한 시스템 콜로 실행됩니다. 컴퓨터는 정말 멋집니다.
fs/binfmt_script.c가 파일이 shebang으로 시작하는지 어떻게 확인하는지 살펴보세요:
/* 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 — 특정 코드 줄을 편집한 모든 사람의 로그 — 를 확인했습니다. 놀랍게도…
컴퓨터는 정말 멋집니다!
Shebang은 커널에 의해 처리되고, 전체 파일을 로드하는 대신 buf에서 가져오기 때문에, 항상buf의 길이로 잘립니다. 분명히, 4년 전에 누군가가 커널이 >128자 경로를 자르는 것에 짜증이 났고, 그들의 해결책은 버퍼 크기를 두 배로 늘려 절단 지점을 두 배로 늘리는 것이었습니다! 오늘날, 여러분의 Linux 머신에서 256자보다 긴 shebang 줄이 있으면 256자를 넘는 모든 것이 완전히 손실됩니다.
이것 때문에 버그가 있다고 상상해보세요. 코드를 망가뜨리는 것의 근본 원인을 알아내려고 노력한다고 상상해보세요. 문제가 Linux 커널 깊숙한 곳에 있다는 것을 발견했을 때 어떤 기분일지 상상해보세요. 경로의 일부가 신비롭게 사라진 것을 발견하는 대규모 기업의 다음 IT 담당자에게 화가 있을 것입니다.
두 번째 이상한 것:argv[0]이 프로그램 이름이라는 것은 관례일 뿐이며, 호출자가 원하는 argv를 exec 시스템 콜에 전달할 수 있고 그것이 통제 없이 통과할 것이라는 것을 기억하시나요?
binfmt_script가 argv[0]이 프로그램 이름이라고 가정하는 곳 중 하나라는 것이 우연히 발생합니다. 항상 argv[0]을 제거하고, 그런 다음 argv의 시작 부분에 다음을 추가합니다:
argv를 업데이트한 후, 핸들러는 linux_binprm.interp를 인터프리터 경로(이 경우 Node 바이너리)로 설정하여 실행을 위한 파일 준비를 마칩니다. 마지막으로, 프로그램 실행 준비 성공을 나타내기 위해 0을 반환합니다.
형식 하이라이트: 기타 인터프리터
또 다른 흥미로운 핸들러는 binfmt_misc입니다. 이것은 /proc/sys/fs/binfmt_misc/에 특수 파일 시스템을 마운트함으로써 사용자 공간 구성을 통해 일부 제한된 형식을 추가할 수 있는 능력을 엽니다. 프로그램은 이 디렉토리의 파일에 특별히 형식화된 쓰기를 수행하여 자체 핸들러를 추가할 수 있습니다. 각 구성 항목은 다음을 지정합니다:
파일 형식을 감지하는 방법. 이것은 특정 오프셋의 매직 넘버나 찾을 파일 확장자를 지정할 수 있습니다.
인터프리터 실행 파일의 경로. 인터프리터 인수를 지정할 방법이 없으므로 원하는 경우 래퍼 스크립트가 필요합니다.
binfmt_misc가 argv를 업데이트하는 방법을 지정하는 하나를 포함한 일부 구성 플래그.
이 binfmt_misc 시스템은 종종 Java 설치에서 사용되며, 0xCAFEBABE 매직 바이트로 클래스 파일을 감지하고 확장자로 JAR 파일을 감지하도록 구성됩니다. 제 특정 시스템에서는 .pyc 확장자로 Python 바이트코드를 감지하고 적절한 핸들러에 전달하도록 구성된 핸들러가 있습니다.
이것은 프로그램 설치 프로그램이 높은 권한의 커널 코드를 작성할 필요 없이 자체 형식에 대한 지원을 추가할 수 있게 하는 꽤 멋진 방법입니다.
결국에는 (Linkin Park 노래가 아님)
exec 시스템 콜은 항상 두 가지 경로 중 하나로 끝날 것입니다:
여러 계층의 스크립트 인터프리터를 거친 후 결국 이해하는 실행 가능한 바이너리 형식에 도달하여 해당 코드를 실행합니다. 이 시점에서 이전 코드는 교체되었습니다.
… 또는 모든 옵션을 소진하고 꼬리를 다리 사이에 끼고 호출 프로그램에 오류 코드를 반환합니다.
Unix 계열 시스템을 사용한 적이 있다면, 터미널에서 실행된 셸 스크립트가 shebang 줄이나 .sh 확장자가 없어도 여전히 실행된다는 것을 알아차렸을 것입니다. 비-Windows 터미널이 있다면 지금 바로 테스트해볼 수 있습니다:
(chmod +x는 OS에 파일이 실행 가능하다고 알려줍니다. 그렇지 않으면 실행할 수 없습니다.)
그렇다면, 왜 셸 스크립트가 셸 스크립트로 실행될까요? 커널의 형식 핸들러는 식별 가능한 레이블이 없는 셸 스크립트를 감지할 명확한 방법이 없어야 합니다!
글쎄요, 이 동작은 커널의 일부가 아니라는 것이 밝혀졌습니다. 실제로 셸이 실패 사례를 처리하는 일반적인 방법입니다.
셸을 사용하여 파일을 실행하고 exec 시스템 콜이 실패하면, 대부분의 셸은 첫 번째 인수로 파일 이름을 사용하여 셸을 실행하여 파일을 셸 스크립트로 다시 실행하려고 시도합니다. Bash는 일반적으로 자신을 이 인터프리터로 사용하는 반면, ZSH는 sh가 무엇이든 사용하며, 일반적으로 Bourne shell입니다.
이 동작이 매우 일반적인 이유는 POSIX에 지정되어 있기 때문입니다. POSIX는 Unix 시스템 간에 코드를 이식 가능하게 만들기 위해 설계된 오래된 표준입니다. POSIX는 대부분의 도구나 운영 체제에 의해 엄격하게 따라지지는 않지만, 많은 관례가 여전히 공유됩니다.
[exec 시스템 콜이] [ENOEXEC] 오류와 동등한 오류로 인해 실패하면, 셸은 명령 이름을 첫 번째 피연산자로 하여 셸을 호출한 것과 동등한 명령을 실행해야 합니다, 나머지 인수는 새 셸에 전달됩니다. 실행 가능한 파일이 텍스트 파일이 아니면, 셸은 이 명령 실행을 우회할 수 있습니다. 이 경우 오류 메시지를 작성하고 종료 상태 126을 반환해야 합니다.