C# - 닷넷에서 프로세스가 열고 있는 파일 목록을 구하는 방법
프로세스(EXE)에서 열고 있는 파일을 열거하는 방법이 마이크로소프트 코드 사이트에 공개되어 있습니다.
File handle operations demo (CppFileHandle)
; https://code.msdn.microsoft.com/windowsapps/CppFileHandle-03c8ea0b
위의 방법은 C++ 코드인데 이를 보고 PInvoke를 이용해 C#으로 변경할 수 있겠지만 이미 그런 작업을 해둔 분이 계십니다. ^^
Enumerate Open/Locked File Handles by Process
; https://gallery.technet.microsoft.com/scriptcenter/Open-Locked-File-Handles-faffd369
그냥 가져다 쓰시면 됩니다. ^^
방법은 대략 다음과 같은 단계로 요약됩니다.
- 임의의 크기로 메모리 할당을 받는다.
- 1번 과정에서 할당받은 영역을 NtQuerySystemInformation에 전달. 만약 핸들 정보를 담기에 메모리가 부족하면 4번째 인자에 필요한 메모리의 크기와 함께 STATUS_INFO_LENGTH_MISMATCH를 반환 (성공한 경우 STATUS_SUCCESS(0)을 반환)
- 2번 과정에서 STATUS_INFO_LENGTH_MISMATCH가 반환된 경우, 4번째 인자에 해당하는 크기의 메모리를 다시 할당받아 NtQuerySystemInformation에 전달. 이번에도 STATUS_INFO_LENGTH_MISMATCH 반환이 나올 수 있는데, 왜냐하면 1번 호출한 이후 그 와중에 새롭게 핸들이 열렸다면 메모리 크기가 늘어나기 때문.
그런데, 위의 소스코드 모두 특정 환경에서 두 번째 NtQuerySystemInformation의 반환값이 STATUS_SUCCESS임에도 불구하고 핸들의 수가 764504201192와 같은 식의 말도 안되는 값이 나오는 경우가 있었습니다. 100% 재현되는 환경은 다음과 같습니다.
- 윈도우 서버 2012 클린 설치
- .NET 4.5로 컴파일(.NET 4.0 이하로 컴파일하면 정상 동작)
위의 이유 때문에 ^^ 지난 글이 써진 것입니다.
.NET 4.0과 .NET 4.5의 컴파일 결과 차이점
; https://www.sysnet.pe.kr/2/0/10831
일단, "High Entropy Virtual Addresses" 옵션이 영향을 주긴 했지만 근본적인 문제는 그것이 아니었습니다.
그럼, 원인 파악을 좀 해볼까요? ^^
문제를 쉽게 하기 위해 C++로 다음의 코드를 작성했습니다.
int length = 0x1000;
LPVOID pVoid = ::GlobalAlloc(0, length);
ULONG returnLength = 0;
NTSTATUS ntResult = NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)16, (LPVOID)pVoid, length, &returnLength);
if (ntResult == STATUS_INFO_LENGTH_MISMATCH)
{
// Round required memory up to the nearest 64KB boundary.
length = ((returnLength + 0xffff) & ~0xffff);
}
::GlobalFree(pVoid);
pVoid = ::GlobalAlloc(0, length);
ntResult = NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)16, (LPVOID)pVoid, length, &returnLength);
if (ntResult == STATUS_SUCCESS)
{
__int64 *lResult = (__int64 *)pVoid;
printf("%I64d\n", *lResult); // 이 값이 바로 열린 핸들의 수를 나타냄
}
else
{
printf("ntquery failed: %d\n", ntResult);
}
위의 코드를 컴파일해 윈도우 서버 2012 (클린 설치)에서 실행하면 문제가 발생하는 것을 확인할 수 있습니다. 위에서는 GlobalAlloc을 사용했지만 "
File handle operations demo (CppFileHandle)" 예제에서처럼 "HeapAlloc(GetProcessHeap(), 0, nSize);" 코드를 사용해도 마찬가지입니다.
제가 가진 윈도우 10에서 실행하면 "123995"와 같은 수가 나오지만 윈도우 서버 2012 R2에서는 "6944656590838556396"와 같은 황당한 수가 나옵니다. 상식적으로 저렇게 많은 핸들이 열렸을 리도 없고 실제로 저 수만 믿고 핸들 정보를 열람하게 되면 STATUS_ACCESS_VIOLATION(0xC0000005) 오류가 발생하게 됩니다.
문제인 즉!
할당된 메모리에 대한 0 초기화가 이뤄져야 한다는 것입니다. 테스트를 위해 두 번째 GlobalAlloc(또는 HeapAlloc) 다음에 다음과 같은 코드를 추가하고,
bool allZero = true;
BYTE *pBytes = (BYTE *)pVoid;
for (int i = 0; i < length; i++)
{
if (*(pBytes) != 0)
{
allZero = false;
break;
}
}
printf("AllZero == %s\n", (allZero == true) ? "true" : "false");
윈도우 서버 2012 R2(클린 설치)에서 실행하면 "AllZero == false"가 출력되지만 다른 운영체제에서는 "AllZero == true"가 나옵니다.
어쩌면 이건 확률상의 문제일지도 모릅니다. 메모리 할당 함수는 일반적으로 VirtualAlloc으로 큰 영역을 할당받고 그 영역 내에서 GlobalAlloc/HeapAlloc과 같은 메모리 할당 함수가 실제로 필요한 영역의 메모리를 요구하게 됩니다. 운이 좋게도 다른 환경에서는 항상 GlobalAlloc/HeapAlloc으로 요구된 메모리가 기존 할당해 놓은 VirtualAlloc의 영역을 초과해 새롭게 "VirtualAlloc(nullptr, length, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);"과 같은 식의 호출을 유발, VirtualAlloc에 의해 최초 메모리가 할당되었을 때는 0으로 초기화가 때문에 문제 없이 NtQuerySystemInformation 호출이 성공한 경우일 수 있습니다.
반면, 윈도우 서버 2012 R2에서는 요청된 2번째 GlobalAlloc/HeapAlloc에서 새로운 메모리가 아닌 기존에 이미 사용중이던 영역의 메모리가 재사용되었고 그곳은 0으로 초기화되지 않았기 때문에 NtQuerySystemInformation 호출에 실패한 것으로 볼 수 있습니다.
실제로 윈도우 서버 2012 R2 이외의 운영체제에서 GlobalAlloc/HeapAlloc으로 할당받았던 메모리에 다음과 같은 식의 쓰레기 값을 채워넣으면 동일한 현상이 재현됩니다.
pVoid = ::HeapAlloc(GetProcessHeap(), 0, length);
BYTE *pBytes = (BYTE *)pVoid;
for (int i = 0; i < length; i++)
{
*(pBytes + i) = 0x60;
}
결국 해결방법은 ^^ 본문에 나온 것처럼 그냥 0으로 초기화만 해주면 됩니다.
pVoid = ::GlobalAlloc(0, length); // 또는 pVoid = ::HeapAlloc(GetProcessHeap(), 0, length);
memset(pVoid, 0, length);
또는 각각의 함수에서 제공하는 0 초기화 옵션을 사용하면 됩니다.
pVoid = ::GlobalAlloc(GMEM_ZEROINIT, length);
// 또는
pVoid = ::HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, length);
닷넷의 경우 동일한 크기의 byte 배열을 만들어 Marshal.Copy를 하는 방법이 있으나 기왕 Win32 API를 interop해서 호출하는 것이므로 차라리 ZeroMemory Win32 API로 해결하는 것이 더 좋습니다.
// NativeMemory.Clear
// .NET Core 3 미만의 경우
[DllImport("Kernel32.dll", EntryPoint = "RtlZeroMemory", SetLastError = false)]
internal static extern void ZeroMemory(IntPtr dest, IntPtr size);
ptr = Marshal.AllocHGlobal(length);
ZeroMemory(ptr, new IntPtr(length));
// .NET Core 3+의 경우
ptr = Marshal.AllocHGlobal(length);
Unsafe.InitBlockUnaligned((byte)ptr.ToPointer(), 0, length);
정리하면, NtQuerySystemInformation 함수에 전달할 메모리는 반드시 0으로 초기화 해줄 것!
(
첨부한 파일은 이 글의 테스트 코드를 포함합니다.)
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]