1. DLL 주소 재배치와 ASLR의 관계
Windows XP 내지 Vista 이후로 (1) 커널 API와 C 런타임 라이브러리 함수 심벌들이 한 DLL 몰빵이 아니라 분야별로 재분류되어 배치되기 시작한 것, (2) 시스템 DLL들이 이제 전혀 rebase되지 않고 고정된 단일 preferred base 주소를 갖기 시작한 것을 보면 참 격세지감이 느껴진다.
위의 둘은 (1) 자잘한 DLL 여러 개보다 큰 DLL 하나가 더 효율적이다(선박처럼??). (2) DLL들은 로딩되는 주소가 겹치지 않게 빌드 후에 반드시 rebasing을 해 줘라
이런 전통적인 고정관념을 역행하는 변화이기 때문이다.
보안 강화를 위해 10여 년 전 Windows Vista 때부터 ASLR (시작 주소 랜덤화)이 도입되면서 DLL은 물론이고 EXE조차도 반드시 자기 preferred base에 고정적으로 로딩이 되지 않게 되었다. 이 때문에 요즘은 EXE도 과거 Win32s 프로그램들처럼 끝에 재배치 정보가 다시 포함돼 들어가고 있다.
하지만 이런 ASLR을 위한 재배치 때는 말 그대로 메모리 오프셋 수정만 행해질 뿐, 재배치의 치명적인 페널티라고 여겨지는 가상 메모리 페이지 파일 재기록이라든가 재사용 불가(여러 프로세스에서 동일 DLL 로딩 시에도 shallow가 아닌 deep copy 발생) 까지 발생하지는 않는다. 운영체제의 보안 기능이 그 정도로 바보는 아니다.
그러므로 오늘날은 DLL을 미리 rebase 하건 안 하건 실행 성능이 달라지는 것은 없다. rebase를 해도 이익을 얻는 것은 없지만 반대로 손해를 보는 것 역시 없다. rebase라는 게 빌드 타임이 아닌 런타임의 영역으로 바뀐 셈이다.
정말 재수가 없어서 엄청 많은 자잘한 DLL들이 로딩되다 보니 한 DLL이 프로세스 A에서는 ASLR 배당 주소로 로딩됐지만 프로세스 B에서는 그 주소로 로딩이 못 되게 됐다면.. 그때는 통상적인 페널티가 부과되는 재배치가 발생할 것이다. 하지만 광대한 주소 공간을 자랑하는 64비트 환경에서는 그럴 가능성이 더욱 희박해졌다.
2. EXE를 LoadLibrary 하기
LoadLibrary 함수는 실행 가능한 코드가 담긴 DLL을 불러오거나 혹은 EXE/DLL로부터 리소스를 얻고자 할 때 즐겨 쓰인다.
그런데 여기서 의문이 든다. LoadLibrary를 호출해서 exe의 단순 리소스가 아니라 코드를 내 프로세스 공간에 가져와 실행하는 게 가능할까?
사실, 기술적으로 볼 때 EXE와 DLL의 차이는 그리 크지 않다. 심지어 EXE도 DLL처럼 심벌 export를 할 수 있다.
그리고 EXE를 LoadLibrary로 그냥 쌩으로 불러와도, 의외로 일단 성공은 한다. GetProcAddress를 해서 심벌을 요청하면 주소값이 돌아오기까지 한다.
하지만 그 함수를 호출해 보면 십중팔구 access violation 에러가 난다. 여기서 대부분의 사람들은 '안 되나 보다'라고 생각하고 단념하게 된다. 왜 이런 현상이 발생하는 것이며, 문제를 해결할 방법은 없는 걸까?
DLL이 아닌 EXE를 LoadLibrary 하면 운영체제는 얘를 반쯤 데이터로 취급하는가 보다. GetProcAddress를 호출했을 때 심벌 검색 결과를 되돌려 주지만 그 포인터가 가리키는 코드를 실행 가능한 상태로 만들어 놓지는 않는다.
특히 (1) 주소 재배치와 관련된 그 어떤 조치도 취하지 않는다. 구체적으로는.. EXE가 사용하는 import table의 주소를 패치하지 않기 때문에 그 EXE의 코드가 실행되면서 Windows API 같은 걸 호출하면 그대로 뻑이 나게 된다.
그리고 (2) EXE의 진입점 함수를 전혀 실행하지 않는다.
EXE건 DLL이건 무조건 맨 먼저 실행할 부분을 가리키는 진입점이란 게 있는데.. 그게 EXE는 int func() 형태이고, DLL은 BOOL func(HMODULE, UINT, PVOID) 형태이다.
즉, EXE는 처음엔 아무 인자 없이 실행됐고 C 라이브러리가 GetStartupInfo 같은 API 함수를 호출해서 실행 인자를 준비한 뒤에 main이나 WinMain을 또 호출하는 형태이다. 그러나 DLL은 진입점 함수의 형태가 DllMain과 완전히 동일하다. 즉, DLL_PROCESS_ATTACH 같은 이벤트 명칭은 이 함수의 호출 인자가 아니면 딴 데서 알아낼 곳이 없다.
LoadLibrary는 원래 DllMain을 호출하게 돼 있는데 EXE는 받아들이는 함수 prototype이 다르므로 아예 호출을 안 하는 것이다.
그러므로 LoadLibrary된 exe의 코드를 강제로 실행한다면 IAT 테이블의 주소가 패치되지 않고 C 라이브러리가 전혀 초기화되지 않은 상태에서 덥석 실행된다. 그 함수에서 내부적으로 전역변수 C++ 객체 같은 걸 사용한다면.. 역시나 제대로 실행되지 못하고 높은 확률로 뻑나게 된다.
IAT 주소를 패치하는 방법까지는 어느 용자가 찾아낸 게 인터넷에 이미 굴러다닌다. (☞ 링크) 이거 패치가 제대로 되려면 EXE는 애초부터 재배치 정보가 들어간 상태로 빌드돼야 한다.
하지만 각종 부작용 없이 C 라이브러리만 감쪽같이 초기화하고 EXE의 export 함수를 실행하는 건.. 굉장히 삽질스럽고 가성비가 낮다. 그냥 EXE와 DLL의 차이가 이러하며 LoadLibrary(EXE)가 기술적으로 왜 권장되지 않는지 이론으로만 알고 넘어가면 될 듯하다.
3. 재빠르게 대체된 파일에 대한 creation date 보정
응용 프로그램 중에는 안전을 위해 문서 저장 기능을 임시 파일을 생성하는 형태로 구현한 것이 있다.
기존 파일을 곧장 덮어써서 저장하는 게 아니라.. 임시 파일에다가 저장을 한 뒤, 기존 파일을 지우고 임시 파일을 기존 파일의 이름으로 바꾼다. 이렇게 하면 저장하는 중에 컴퓨터에 전기가 나가는 등의 이상 현상이 발생하더라도 최소한 기존 자료가 송두리째 날아가는 일은 막을 수 있다.
그런데 이렇게 기존 파일을 덮어쓰는 게 아니라 파일 자체를 딴 것으로 대체하는 식으로 저장을 하면 기존 파일이 갖고 있는 creation time이 보존되지 않게 된다. 그렇기 때문에 기존 파일의 creation time을 따로 얻어 놓은 뒤, 저장을 마친 새 파일에 대해서 creation time을 SetFileTime 함수로 따로 지정해 줘야 한다.
단, Windows NT 계열의 경우, 놀랍게도 보정 동작을 진작부터 지원하고 있었다. 어떤 프로그램이 A라는 파일을 삭제한 뒤에 다른 파일의 이름을 A로 신속하게 거의 곧장 변경한 경우, 그 파일에다가 삭제된 A의 creation time을 자동으로 지정해 줬던 것이다~!
이런 보정을 위해서는 파일 삭제와 개명 알고리즘에다가 삭제된 파일의 생성 시각을 백업해 놓고, 시간차를 감지해서 이 renaming이 기존 파일을 승계하는 동작인지 판단하는 등 여러 귀찮은 작업이 필요할 것이다. 하지만 마소에서는 임시 파일 방식으로 저장하면서 creation time을 관리하지 않는 프로그램이 많은 것을 감안하여 운영체제 차원에서 이런 보정 기능을 구현했다고 한다.
이 보정은 NT 계열에서만 지원되어 왔으며, 9x 계열에서는 존재하지 않는다.
4. 스레드 동기화 deadlock 자동 감지
복잡한 메모리 문제를 잡아내기 위해 C 라이브러리 차원에서 저런 다양한 안전 장치와 디버깅 편의 기능이 제공되듯, 멀티스레드 동기화 오브젝트에도 디버그 버전용은 데드락 정도는 assertion failure 에러를 내면서 곧장 감지하는 기능이 있으면 좋겠다는 생각이 든다.
“당신이 지금 취득을 위해 대기하려는 뮤텍스는 현재 다른 스레드가 잡고 있는데, 문제는 그 스레드도 지금 당신이 요 스레드에서 잡고 있는 뮤텍스를 얻으려고 대기 중이다. 그러니 이 상태로는 상호 무한 대기 교착 상태가 됨.”
이건 레퍼런스 카운트 기반인 오브젝트에서 순환 참조 오류를 감지하는 기능을 구현하는 것과 기술적으로 완전히 동급이다.
Hash 같은 컨테이너를 둬서 스레드 ID별로 각각 현재 진입해 있는 뮤텍스에 대한 기록을 관리하고, 뮤텍스 오브젝트를 감싸는 클래스에다가 현재 자신을 잡고 있는 스레드 정보도 같이 보관하는 정도의 수고만 하면 큰 어려움 없이 구현 가능하다.
하지만 PC용 프로그램에서 돌아가는 스레드의 개수가 무슨 할당된 동적 메모리 블록 개수처럼 많을 리는 없을 것이고, 프로그램의 응답이 멎었을 때 데드락 부위를 찾는 것은 도구의 도움 없이 도저히 못 할 일은 아닐 것이다. 유용성에 비해 저런 기능을 갖추는 건 속도와 메모리 오버헤드가 너무 커서 가성비가 맞지 않으니 데드락 자동 감지 기능은 운영체제나 프로그래밍 언어 런타임이 제공해 주지 않는 듯하다.
개인적으로 직장에서는 심지어 자기 스레드 자신의 실행이 끝나기를 기다리는.. C++ 오브젝트로 치면 delete this.. 무슨 자살이나 다름없는 deadlock도 경험한 적이 있었다.
프로그램의 실행이 종료되어 UnInit() 함수가 호출될 때는 백그라운드 작업 스레드에 대해서도 작업을 중단시키고 작업 스레드의 실행이 끝날 때까지 기다리게 했는데, 뭔가 로직이 꼬여서 작업 스레드에서 UnInit()를 호출하는 상황이 발생한 것이다.
Uninit이 무슨 loop 안에서 1초에 수십, 수백 번씩 실행되어서 성능이 중요한 함수인 건 아니다. 그러니 자기 자신이 무슨 스레드 문맥에서 실행되었는지 검사해서 deadlock을 피할 수도 있다.
하지만 그것보다는 Uninit이 스레드 함수가 아니라 의도했던 대로 main thread에서만 실행되도록 프로그램 구조를 고치는 것이 훨씬 더 나은 해결책이었다.
Posted by 사무엘