본문 바로가기

공부/커널

커널 소스 분석 1. system call trace

728x90

1. System call


시스템 콜은 운영체제의 커널이 제공하는 함수에 대해, 어플리케이션의 요청에 따라 커널에 접근하기 위한 방법으로 정의할 수 있습니다. 일반 어플리케이션은 커널의 자원을 마음대로 제어할 수 없기 때문에 커널에 자원을 요청해야 하는 경우 시스템 콜을 발생해 커널 함수를 호출하고, 자원을 요청할 수 있습니다.


출처 : 위키피디아 - https://ko.wikipedia.org/wiki/%EC%8B%9C%EC%8A%A4%ED%85%9C_%ED%98%B8%EC%B6%9C



이 그림은 시스템 콜을 검색하면 아직도 많이 볼 수 있는 그림입니다. 

(커널 세미나 자료에도 이 자료가 포함돼있더라는....)


이 그림에서 나타내는 시스템 콜의 순서는 다음과 같습니다.

1) 유저 어플리케이션에서 시스템 함수를 사용할 경우 함수가 포함되어있는 libc.a 라고 하는 오브젝트 파일을 참조

2) libc.a에 선언되어 있는 함수는 필요한 경우 레지스터에 매개변수들을 저장한 후에 "int 0x80" 명령어로 인터럽트 발생

3) 시스템 콜이 발생하면 운영체제는 수행하던 프로세스를 중지하고 IDT 를 참조하여 해당 인터럽트의 원인을 찾음

4) 인터럽트의 발생 원인이 시스템 콜일경우 커널 내에서 시스템 콜을 참조하는 루틴 호출

(시스템 콜 테이블을 호출하며 이때 매개 변수로는 레지스터에 저장된 값과 엔트리의 크기가 전달됨)

5) 시스템 콜 테이블에서 해당 시스템 콜을 찾은후 해당 루틴으로 이동


하지만 현재 시스템콜은 libc.a가 아닌 libc.so를 참조하게 됩니다. 


libc.a는 정적 오브젝트(static object)로 컴파일의 링킹 단계에서 라이브러리의 필요한 부분을 응용프로그램으로 복사하는 방법을 사용하게 됩니다. 이 방법의 문제점은 컴파일 후 실행 파일의 용량이 커지며, 정적 오브젝트 내의 소스코드가 수정될 경우 이 오브젝트를 참조하고 있는 응용 프로그램은 재 컴파일 유무에 따라 서로 다른 함수를 사용하게 됩니다.


2. .so


문제점을 해결하기 위한 방법이 다이나믹 오브젝트(dynamic object)입니다. 윈도우에서는 dll이라는 개념으로도 사용되는 개념으로

운영체제에서는 공통적으로 사용되는 시스템 함수들을 모아 별도로 관리합니다.

이전과는 다르게 컴파일일 과정이 조금 더 복잡해졌습니다.

먼저 컴파일만 수행할 경우 시스템 함수가 들어갈 공간은 주소값 만을 갖게 됩니다.(사실 .a에서 이부분은 아직 확인해보지 못했습니다) 링킹 단계에서도 링커는 함수를 콜한다는 표시 정도만 남겨두게 됩니다.



위 그림은 .so에서 링킹이 된 후의 모습을 모이고 있습니다. 빨간줄로 표시된 callq 0x4004c0 <puts@plt>부분이 바로 링커에 의해 함수의 포함 여부만 표시된 부분입니다. 


그리고 어플리케이션은 실행 단계에서야 함수를 참조하려 하고 만약 함수가 외부 함수(시스템함수)일 경우 그제서야 함수를 어플리케이션 내에 포함하게 됩니다. 


3. PLT, GOT


얼핏 보기로는 call 이라고 하는 어셈블리어 명령어를 통해 puts를 호출하는 것처럼 보이고 있으며, 이 소스만으로는 .a를 사용할 때와 크게 다를게없어 보일수도 있습니다. 지금부터 이 과정을 따라갈 것이며, 그전에 먼저 PLT, GOT에 대한 개념 정리가 필요합니다.


먼저 PLT(Procedure Linkage Table)프로시저를 연결해주는 테이블로써, 어플리케이션에서 포함하고 있는 외부 프로시저들을 호출할 때 이 함수들을 연결하는 테이블입니다. 그렇다면 외부 프로시저들이 어디에 있는지 알아야 합니다. 이 리스트를 나타내주는 것이 GOT(Global Offset Table)입니다.


출처 : http://lapislazull.tistory.com/54 




출처 : https://blog.flameeyes.eu/2012/10/symbolism-and-elf-files-or-what-does-bsymbolic-do


프로그램에서 함수를 호출할 경우 함수는 PLT와 GOT를 차례로 참조하게 됩니다. 함수가 처음 호출되는 함수라면 got에서는 다시 plt의 다음 실행 위치로 점프를 하고, plt에서는 스택에 got의 offset을 넣게 됩니다. 그리고 다이나믹 링커를 호출함으로써 해당 함수를 링킹합니다. 링킹과정에서는 오브젝트 파일들 중에서 해당 함수를 찾고, 프로그램의 메모리로 복사해오게 됩니다. 그리고 got의 테이블은 plt로 점프하던 주소 대신 메모리로 복사된 함수의 주소를 가리킵니다. 

다음 그림은 함수에서 plt와 got를 참조하여 함수를 so의 함수를 참조하는 모습을 보입니다.


위 그림에서는 총 2번의 동일한 함수를 호출했을 경우 모습을 보이고 있습니다. before와 after로 표시 되어 있는데 링커의 작업을 기준으로 함수가 메모리에 복사되기 전이 before, 복사 후가 after를 나타냅니다.


4. 소스 분석


PLT와 GOT를 분석하기 위해 다음과 같은 소스 코드를 작성해봤습니다



앞서 설명했듯이 동일한 함수를 두 번 호출할 경우 GOT에서 점프하는 주소 값이 달라지며, 이 주소에 따라 링커의 작동 여부가 결정됩니다. 이 과정을 확인해보기 위해 2번의 printf를 호출합니다. (이후 printf로 프로세스 아이디를 출력하는 것은 메모리 맵을 확인하기 위함입니다)

컴파일을 해보았습니다




이 그림은 실행 파일로 만들어서 어셈블리어 내용을 확인해 본 결과입니다.



printf함수를 출력했지만 puts가 보입니다. 이는 컴파일러 최적화에 의해 함수가 대체된 것입니다. printf함수는 출력할 문자열의 포맷을 맞춰주는 동시에 출력해줍니다. (아마 내부 과정에서 데이터 타입이나 몇가지 확인하는 작업을 거치겠죠.....) 하지만 데이터 포맷을 맞출필요가 없는 그냥 순수 문자열만 출력을 한다면... 함수 내부에서 복잡한 과정없이 puts로 대체해서 출력을 하게 되는것입니다. (반면 3번째 printf는 %d와 같은 포맷을 맞추기 때문에 printf@plt를 호출하고있습니다.)



디버깅을 위해 몇군데 브레이크 포인트를 걸어줍니다

(하다보니 딱히 필요가 없을거같기도 한데....)

그리고 puts의  PLT 위치인 0x4004c0의 내용을 확인해 보도록 하겠습니다.




x(출력) 명령어로 3줄의 코드를 출력해보면 위 그림과 같습니다. 가장 먼저 jump를 어떤 주소로 하게 되는데 가운데 보시면 puts@got.plt라고 되어 있습니다. 여기가 GOT의 주소가 됩니다. GOT의 주소인 0x601018의 내용을 확인해 보면 0x004004c6의 값을 가지고 있게 됩니다. 이 주소는 PLT에서 GOT로 점프한 다음의 명령어 주소가 됩니다. 즉 처음 함수가 호출되는 단계에서는 GOT를 참조하지만 GOT의 주소에서는 PLT의 다음 명령어 주소를 가리키고 있는 모습을 볼 수 있습니다.

그리고 PLT에서는 스택에 GOT의 인텍스를 푸쉬하고 0x4004b0의 위치로 점프하게 됩니다.

직관적으로 이 위치는 현재 puts의 plt위치와 얼마 멀지 않음을 알 수 있습니다.



objdump를 이용해 PLT 테이블의 전체 구조를 보면 다음과 같습니다. 일단 직전에 보았던 plt의 내용이 보이고 한참 위에서 형식을 지정해 printf를 했던 구문때문에 printf의 PLT가 별도로 보입니다. 그리고 테이블의 맨 위, 특별한 이름으로 지정은 안되어 있고 그냥 puts@plt-0x10의 위치로 되어 있습니다(이유는 딱히...모르...음....)


어쨋거나 테이블 구조상에서 맨 위의 테이블 인덱스를 참조함을 알 수 있습니다. 즉 다이나믹 링커의 인덱스는 plt의 0번째 인덱스가 됩니다.

지금까지 순서를 정리하면 다음과 같습니다.


코드에서 함수를 호출할 경우 PLT를 참조하고, PLT는 GOT로 이동하지만 GOT는 PLT의 다음 실행 명령어 주소를 가지고 있습니다. PLT는 GOT인덱스를 스택에 넣은 후 PLT의 0번째 인덱스로 이동됩니다. 마지막으로 PLT의 0번째 인덱스는 resolver를 호출하게 되는데 이 함수의 이름은 _dl_runtime_resolve()입니다.

이 과정을 si 명령어를 통해 따라가면 다음과 같은 순서가 됩니다.




(특이한 점은 _dl_rumtime_resolve함수 역시 printf를 통해 PLT와 GOT를 참조하기 전에는 메모리에 존재하지 않는다는 점입니다.

printf에 걸린 브레이크 포인트 직전에 브레이크 포인트를 하나 더 걸어 확인해보면 _dl_runtime_resolve함수를 볼 수 없습니다. )

그림에서 보는것과 같이 plt의 0번째 인덱스를 참조하여 점프한 위치를 따라가보면 _dl_runtime_resolve()함수가 호출되고 있습니다.



앞에서 살짝 설명했듯이 함수 호출전에는 _dl_runtime_resolve함수를 disas 명령어를 써도 볼 수 없었는데 함수 호출 후에는 disas를 사용하면 _dl_runtime_resolve함수의 내용을 볼 수 있습니다(하지만 어셈블리어에 서툴기에 소스코드를 다운받아 같이 보도록 하겠습니다....만 소스 코드에서도 이 함수가 c로 작성됬다고는 안했......다행이 주석....)


주석대로라면 link_map이라고 하는 자료구조와 reloc_offset이라고 하는 변수가 매개변수로 전달되며 _dl_fixup함수를 호출하고 있습니다

앞으로 설명할 내용은 커널보다도 컴파일러 개론 그중에서도 링커와 관련된 내용을 알고 있어야 이해가 편하기 때문에 필요하신 분들은 아래 내용을 참고해주세요




_dl_fixup 함수를 보기 위해 libc.so의 소스를 해당 홈페이지에서 다운 받아 열어봤습니다.

심볼 테이블의 위치인 symtab과 strtab이름을 가진 변수명을 확인해 볼 수 있습니다.

그리고 그 아래 함수를 해당 프로그램에 포함 했을 때 재배치 되는 위치를 가리키는 변수 reloc과 rel_addr, 테이블로부터 하나의 심볼을 가리키는 sym의 변수를 확인해 볼수 있으며, _dl_lookup_symbol_x함수를 호출해 해당 심볼을 가지고 있는 오브젝트 파일과 함수를 검색하게 됩니다.




_dl_lookup_symbol_x함수의 처리 과정은 다음과 같습니다. 


symtab와 라이브러리의 주소를 얻어오고 얻어온 주소를 어플리케이션에 추가합니다.

그리고나서 elf_machine_plt_value와 elf_machine_fixup_plt함수를 부르는데 딱히 중요한 역할을 하는거같진 않습니다.




모든 처리를 마치고나면 다시 _dl_runtime_resolve함수로 돌아오게 되는데 리턴된 주소값을 저장하고 jmp명령어를 통해 해당 함수로 점프되는것을 볼 수 있습니다.




그리고나서 다시한번 printf함수를 호출한다면 위 그림과 같이 주소값이 변함을 볼 수 있습니다.

즉 두번 째 호출에서는 plt-got-해당함수 순서로 이동됩니다. 여기서 한가지 이상한점은 함수의 점프되는 주소는 0xf7a82e10이고 실제 이동되는 주소(그리고 메모리에 매핑된 주소)는 0x00007fff가 붙습니다.... 스택의 위치때문이거나 라이브러리들이 별도의 메모리에 모여서 매핑되는거같은데.. 정확하지가 않습... 혹시 이유아시는 분들은 연락좀...


이제 libc의 함수가 매핑되는 부분까지 왔으니 매핑된 함수가 어떻게 시스템콜을 하는지 살펴보도록 하겠습니다.

처음엔 별 생각없이 인터넷 글들을 보고 printf함수를 썼는데... 버퍼를 다루는 내용때문인지 함수의 내용이 상당히...긴...

여기서부터는 fork함수를 쓰도록 하겠습니다.




printf와 마찬가지로 _dl_runtime_resolve함수를 통해 처리한 후 해당 함수로 점프하는데 이번에는 __libc_fork 함수로 점프합니다.

__libc_fork의 함수를 어셈블리어로 보면 다음과 같습니다.




위 그림을 보면 syscall 이라고 하는 라인을 볼 수 있습니다.

일반적으로  시스템콜은 int &0x80을 통해 부른다고 설명하며 맨 처음 그림에서도 이와 같이 나와있습니다.

https://en.wikibooks.org/wiki/X86_Assembly/Interfacing_with_Linux

위 문서에 따르면 x86에서는 int &0x80과 systcall을 함께 사용하고 특히 x86_64에서는 syscall만 사용합니다.

그리고 syscall을 할 경우 IDT를 참조할 필요 없이 바로 커널내의 해당 시스템콜 처리 루틴으로 이동됩니다.

즉 정리해보면 다음과 같습니다.




첫번째 시스템 함수를 호출할 경우 해당 프로세스는 plt를 거쳐 got를 참조하며 이때 got는 비어있어서 plt의 다음 명령어를 가리킵니다. plt의 다음 명령어는 plt[0]으로 이동시키며 plt[0]에서는 resolver를 호출해 해당 함수를 오브젝트에서 찾아 연결하고 함수의 주소를 반환합니다.

그리고 해당 함수로 이동하고 난 후에는 syscall명령어를 통해 시스템콜을 호출하며 커널로 이동하게 됩니다.

두번째 동일한 함수를 호출할 경우 plt와 got를 거치며, 해당 함수가 로딩돼어 있기 때문에 got의 주소는 변환되어 있기 때문에 해당 함수로 바로 이동할 수 있습니다.



* 급하게 정리하느냐고 출처를 모두 달지 못했습니다. 최대한 빨리 달도록 하겠습니다.

* 이상한 부분이나 궁금한 부분 알려주시면 더 추가하도록 하겠습니다.

'공부 > 커널' 카테고리의 다른 글

qemu + gdb 연동  (0) 2018.07.11
[프로젝트] 커널레벨 DSM  (0) 2017.06.29
asmlinkage에 대해  (0) 2014.09.02