성태의 닷넷 이야기
홈 주인
모아 놓은 자료
프로그래밍
질문/답변
사용자 관리
사용자
메뉴
아티클
외부 아티클
유용한 코드
온라인 기능
MathJax 입력기
최근 덧글
[정성태] VT sequences to "CONOUT$" vs. STD_O...
[정성태] NetCoreDbg is a managed code debugg...
[정성태] Evaluating tail call elimination in...
[정성태] What’s new in System.Text.Json in ....
[정성태] What's new in .NET 9: Cryptography ...
[정성태] 아... 제시해 주신 "https://akrzemi1.wordp...
[정성태] 다시 질문을 정리할 필요가 있을 것 같습니다. 제가 본문에...
[이승준] 완전히 잘못 짚었습니다. 댓글 지우고 싶네요. 검색을 해보...
[정성태] 우선 답글 감사합니다. ^^ 그런데, 사실 저 예제는 (g...
[이승준] 수정이 안되어서... byteArray는 BYTE* 타입입니다...
글쓰기
제목
이름
암호
전자우편
HTML
홈페이지
유형
제니퍼 .NET
닷넷
COM 개체 관련
스크립트
VC++
VS.NET IDE
Windows
Team Foundation Server
디버깅 기술
오류 유형
개발 환경 구성
웹
기타
Linux
Java
DDK
Math
Phone
Graphics
사물인터넷
부모글 보이기/감추기
내용
<div style='display: inline'> <h1 style='font-family: Malgun Gothic, Consolas; font-size: 20pt; color: #006699; text-align: center; font-weight: bold'>windbg - Marshal.FreeHGlobal에서 발생한 덤프 분석 사례</h1> <p> 이번 글은 이전에 쓴 ODAC 문제의,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > windbg - CoTaskMemFree/FreeCoTaskMem에서 발생한 덤프 분석 사례 - 두 번째 이야기 ; <a target='tab' href='http://www.sysnet.pe.kr/2/0/12062'>http://www.sysnet.pe.kr/2/0/12062</a> </pre> <br /> 후기입니다. ^^<br /> <br /> 일단, 해당 고객사에서 ODAC을 <a target='tab' href='http://www.sysnet.pe.kr/2/0/10928'>Managed Driver</a>로 바꿨는데 그래도 (비록 횟수는 줄었지만) 비정상 종료가 발생한다고 합니다. 오호~~~, 그렇다면 ODAC 내에서의 버그가 아니고 다른 구성 요소가 Heap을 깨 먹었다는 이야기가 되는데요. 어쨌든 고객사 측에서 비정상 종료 시의 덤프를 3개 떠서 보내왔고 다행히 그 안에 원래의 버그를 발생시킨 콜 스택이 있었습니다.<br /> <br /> 어디... 분석을 들어가 볼까요? ^^<br /> <br /> <hr style='width: 50%' /><br /> <br /> 첫 번째 덤프는 다음의 call stack에서 crash가 발생했지만,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > // .NET Call Stack [[HelperMethodFrame_PROTECTOBJ] (System.String.ReplaceInternal)] System.String.ReplaceInternal(System.String, System.String) System.String.Replace(System.String, System.String)+f TestApp.MyUtil.ConvertDate(System.String)+227 TestApp.MyEditor.ConvertText(Int32, System.String, System.String, System.Collections.Hashtable ByRef)+5b7 TestApp.MyEditor.Page_Load(System.Object, System.EventArgs)+160e System.Web.UI.Control.OnLoad(System.EventArgs)+68 System.Web.UI.Control.LoadRecursive()+70 System.Web.UI.Page.ProcessRequestMain(Boolean, Boolean)+8a5 ...[생략]... System.Web.Hosting.PipelineRuntime.ProcessRequestNotification(IntPtr, IntPtr, IntPtr, Int32)+13 DomainNeutralILStubClass.IL_STUB_ReversePInvoke(Int64, Int64, Int64, Int32)+52 [[ContextTransitionFrame]] // Full Call Stack ntdll!RtlReportCriticalFailure+97 ntdll!RtlpHeapHandleError+12 ntdll!RtlpLogHeapFailure+96 ntdll!RtlFreeHeap+823 clr!EEHeapFreeInProcessHeap+45 clr!CQuickMemoryBase<512,128>::Destroy+41 clr!COMString::ReplaceString+3e4 System.String.Replace(System.String, System.String)+f TestApp.MyUtil.ConvertDate(System.String)+227 TestApp.MyEditor.ConvertText(Int32, System.String, System.String, System.Collections.Hashtable ByRef)+5b7 TestApp.MyEditor.Page_Load(System.Object, System.EventArgs)+160e System.Web.UI.Control.OnLoad(System.EventArgs)+68 System.Web.UI.Control.LoadRecursive()+70 ...[생략]... clr!Thread::intermediateThreadProc+8b kernel32!BaseThreadInitThunk+14 ntdll!RtlUserThreadStart+21 </pre> <br /> 이것도 또 다른 희생자일 뿐 문제의 원인과는 상관없습니다. (만약 고객사가 이번의 덤프 하나만 보내줬다면 원인을 찾을 수 없었을 것입니다.) 그래도 <a target='tab' href='http://www.sysnet.pe.kr/2/0/12063'>RtlReportCriticalFailure로부터 힙이 깨졌음을 확인</a>할 수는 있습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > 0:044> <span style='color: blue; font-weight: bold'>.exr -1</span> ExceptionAddress: 00007ff8678882d3 (ntdll!RtlReportCriticalFailure+0x0000000000000097) ExceptionCode: c0000374 ExceptionFlags: 00000001 NumberParameters: 1 Parameter[0]: <span style='color: blue; font-weight: bold'>00007ff8678df6b0</span> 0:044> <span style='color: blue; font-weight: bold'>dq /c1 00007ff8678df6b0 L4</span> 00007ff8`678df6b0 000006e0`00000002 00007ff8`678df6b8 00000000`00000004 00007ff8`678df6c0 00000190`4efb0000 00007ff8`678df6c8 <span style='color: blue; font-weight: bold'>00000194`9ef50580</span> 0:044> <span style='color: blue; font-weight: bold'>dq /c1 00000194`9ef50580 L4</span> 00000194`9ef50580 00000000`00000000 00000194`9ef50588 <span style='color: blue; font-weight: bold'>90000000`00000000</span> 00000194`9ef50590 00000000`00000000 00000194`9ef50598 00000000`00000000 </pre> <br /> <hr style='width: 50%' /><br /> <br /> 두 번째 덤프에서 다행히 힙을 깬 범인을 접하게 되었습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > // .NET Call Stack DomainNeutralILStubClass.IL_STUB_PInvoke(IntPtr)+6e [[InlinedCallFrame] (Microsoft.Win32.Win32Native.LocalFree)] Microsoft.Win32.Win32Native.LocalFree(IntPtr) <span style='color: blue; font-weight: bold'>System.Runtime.InteropServices.Marshal.FreeHGlobal(IntPtr)+22</span> <span style='color: blue; font-weight: bold'>TestApp.Thumbnail.GetThumbnailImage(System.String, Int32)+2cf</span> TestApp.FileInfoApi.Page_Load(System.Object, System.EventArgs)+342 System.Web.UI.Control.OnLoad(System.EventArgs)+68 System.Web.UI.Control.LoadRecursive()+70 System.Web.UI.Page.ProcessRequestMain(Boolean, Boolean)+8a5 ...[생략]... System.Web.Hosting.PipelineRuntime.ProcessRequestNotificationHelper(IntPtr, IntPtr, IntPtr, Int32)+425 System.Web.Hosting.PipelineRuntime.ProcessRequestNotification(IntPtr, IntPtr, IntPtr, Int32)+13 DomainNeutralILStubClass.IL_STUB_ReversePInvoke(Int64, Int64, Int64, Int32)+52 [[ContextTransitionFrame]] // Full Call Stack ntdll!RtlReportCriticalFailure+97 ntdll!RtlpHeapHandleError+12 ntdll!RtlpLogHeapFailure+96 ntdll!RtlFreeHeap+823 KERNELBASE!LocalFree+2e DomainNeutralILStubClass.IL_STUB_PInvoke(IntPtr)+6e System.Runtime.InteropServices.Marshal.FreeHGlobal(IntPtr)+22 TestApp.Thumbnail.GetThumbnailImage(System.String, Int32)+2cf TestApp.FileInfoApi.Page_Load(System.Object, System.EventArgs)+342 System.Web.UI.Control.OnLoad(System.EventArgs)+68 System.Web.UI.Control.LoadRecursive()+70 ...[생략]... clr!Thread::intermediateThreadProc+8b kernel32!BaseThreadInitThunk+14 ntdll!RtlUserThreadStart+21 </pre> <br /> 물론 이것 역시 또 다른 희생자일 수 있으므로 저것만 봐서는 확신할 수 없습니다. 단지 call stack에서 사용자가 작성한 TestApp.Thumbnail.GetThumbnailImage 메서드에서 명시적으로 Marshal.FreeHGlobal을 호출했다는 사실에서, 대부분의 버그는 사용자 코드에서 발생하기 때문에 잠정적인 후보로 채택할 수 있습니다.<br /> <br /> <hr style='width: 50%' /><br /> <br /> 그리고 마침내, 세 번째 덤프를 보고 확신이 좀 들었습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > // .NET Call Stack DomainNeutralILStubClass.IL_STUB_PInvoke(IntPtr)+6e [[InlinedCallFrame] (Microsoft.Win32.Win32Native.LocalFree)] Microsoft.Win32.Win32Native.LocalFree(IntPtr) <span style='color: blue; font-weight: bold'>System.Runtime.InteropServices.Marshal.FreeHGlobal(IntPtr)+22 TestApp.Thumbnail.GetThumbnailImage(System.String, Int32)+2cf </span> TestApp.FileInfoApi.Page_Load(System.Object, System.EventArgs)+342 System.Web.UI.Control.OnLoad(System.EventArgs)+68 System.Web.UI.Control.LoadRecursive()+70 ...[생략]... System.Web.Hosting.PipelineRuntime.ProcessRequestNotification(IntPtr, IntPtr, IntPtr, Int32)+13 DomainNeutralILStubClass.IL_STUB_ReversePInvoke(Int64, Int64, Int64, Int32)+52 [[ContextTransitionFrame]] // Full Call Stack ntdll!RtlReportCriticalFailure+97 ntdll!RtlpHeapHandleError+12 ntdll!RtlpLogHeapFailure+96 ntdll!RtlFreeHeap+823 KERNELBASE!LocalFree+2e DomainNeutralILStubClass.IL_STUB_PInvoke(IntPtr)+6e System.Runtime.InteropServices.Marshal.FreeHGlobal(IntPtr)+22 TestApp.Thumbnail.GetThumbnailImage(System.String, Int32)+2cf TestApp.FileInfoApi.Page_Load(System.Object, System.EventArgs)+342 System.Web.UI.Control.OnLoad(System.EventArgs)+68 System.Web.UI.Control.LoadRecursive()+70 System.Web.UI.Page.ProcessRequestMain(Boolean, Boolean)+8a5 ...[생략]... clr!Thread::intermediateThreadProc+8b kernel32!BaseThreadInitThunk+14 ntdll!RtlUserThreadStart+21 </pre> <br /> 자.. 그럼 도대체 TestApp.Thumbnail.GetThumbnailImage 메서드에서는 어떤 일이 벌어지고 있었던 걸까요? 우선, 해당 메서드를 역어셈블하면 다음과 같은 식의 코드를 볼 수 있습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > public Image GetThumbnailImage(<span style='color: blue; font-weight: bold'>string filePath</span>, int sizepic) { // ...[생략]... <span style='color: blue; font-weight: bold'>intPtr2 = Marshal.AllocHGlobal(1024);</span> try { int num3 = 0; Thumbnail.IExtractImage extractImage2 = extractImage; IntPtr pszPathBuffer = intPtr2; num = 0; extractImage2.GetLocation(pszPathBuffer, 1024, ref num, ref size, 32, ref num3); extractImage.Extract(ref hbitmap); } catch (Exception ex2) { } if (!hbitmap.Equals(IntPtr.Zero)) { result = Image.FromHbitmap(hbitmap); } if (intPtr2 != IntPtr.Zero) { <span style='color: blue; font-weight: bold'>Marshal.FreeHGlobal(intPtr2); // crash가 발생한 코드</span> } // ...[생략]... } </pre> <br /> 그렇다면 문제가 발생한 시점의 GetThumbnailImage에 전달된 filePath의 값을 추적해 보겠습니다. 이를 위해 우선 !clrstack 명령어를 통해 해당 메서드가 수행된 frame의 "Child SP"와 "IP" 값을 알아내고,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > 0:040> !clrstack OS Thread Id: 0x20cc (40) Child SP IP Call Site 0000007e357be4d8 00007ff8678882d3 [InlinedCallFrame: 0000007e357be4d8] Microsoft.Win32.Win32Native.LocalFree(IntPtr) 0000007e357be4d8 00007ff7f7a6958e [InlinedCallFrame: 0000007e357be4d8] Microsoft.Win32.Win32Native.LocalFree(IntPtr) 0000007e357be4b0 00007ff7f7a6958e DomainNeutralILStubClass.IL_STUB_PInvoke(IntPtr) 0000007e357be560 00007ff7f854d502 System.Runtime.InteropServices.Marshal.FreeHGlobal(IntPtr) <span style='color: blue; font-weight: bold'>0000007e357be5a0</span> <span style='color: blue; font-weight: bold'>00007ff7f8be80ff</span> TestApp.Thumbnail.GetThumbnailImage(System.String, Int32) 0000007e357be690 00007ff7f8bdaf82 TestApp.FileInfoApi.Page_Load(System.Object, System.EventArgs) 0000007e357be750 00007ff7f84dc768 System.Web.UI.Control.OnLoad(System.EventArgs) 0000007e357be790 00007ff7f84dc5b0 System.Web.UI.Control.LoadRecursive() ...[생략]... 0000007e357bf840 00007ff7f838abd2 DomainNeutralILStubClass.IL_STUB_ReversePInvoke(Int64, Int64, Int64, Int32) 0000007e357bfa18 00007ff856ea2473 [ContextTransitionFrame: 0000007e357bfa18] </pre> <br /> IP 값을 대상으로 코드를 덤프합니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > 0:040> <span style='color: blue; font-weight: bold'>!u 00007ff7f8be80ff</span> Normal JIT generated code TestApp.Thumbnail.GetThumbnailImage(System.String, Int32) Begin 00007ff7f8be7e30, size 36e 00007ff7`f8be7e30 55 push rbp 00007ff7`f8be7e31 4156 push r14 00007ff7`f8be7e33 57 push rdi 00007ff7`f8be7e34 56 push rsi 00007ff7`f8be7e35 53 push rbx 00007ff7`f8be7e36 4881ecc0000000 sub rsp,0C0h 00007ff7`f8be7e3d 488dac24e0000000 lea rbp,[rsp+0E0h] 00007ff7`f8be7e45 488bf1 mov rsi,rcx 00007ff7`f8be7e48 488dbd68ffffff lea rdi,[rbp-98h] 00007ff7`f8be7e4f b91e000000 mov ecx,1Eh 00007ff7`f8be7e54 33c0 xor eax,eax 00007ff7`f8be7e56 f3ab rep stos dword ptr [rdi] 00007ff7`f8be7e58 488bce mov rcx,rsi 00007ff7`f8be7e5b 4889a558ffffff mov qword ptr [rbp-0A8h],rsp 00007ff7`f8be7e62 488bf9 mov rdi,rcx <span style='color: blue; font-weight: bold'>00007ff7`f8be7e65 488bf2 mov rsi,rdx // filePath</span> 00007ff7`f8be7e68 418bd8 mov ebx,r8d 00007ff7`f8be7e6b 488bce mov rcx,rsi // filePath 00007ff7`f8be7e6e e89dff2bff call 00007ff7`f7ea7e10 (System.String.IsNullOrWhiteSpace(System.String), mdToken: 00000000060004cb) 00007ff7`f8be7e73 84c0 test al,al 00007ff7`f8be7e75 750c jne 00007ff7`f8be7e83 00007ff7`f8be7e77 488bce mov rcx,rsi // filePath 00007ff7`f8be7e7a e8f1bbe5fe call 00007ff7`f7a43a70 (System.IO.File.Exists(System.String), mdToken: 00000000060017a7) ...[생략]... 00007ff7`f8be7ec6 e845362dff call 00007ff7`f7ebb510 (System.Guid..ctor(System.String), mdToken: 0000000006000ed1) 00007ff7`f8be7ecb 488bce mov rcx,rsi // filePath 00007ff7`f8be7ece e85dfb43ff call 00007ff7`f8027a30 (System.IO.Path.GetDirectoryName(System.String), mdToken: 0000000006001900) 00007ff7`f8be7ed3 4c8bf0 mov r14,rax 00007ff7`f8be7ed6 488bce mov rcx,rsi // filePath 00007ff7`f8be7ed9 e8a21a0dff call 00007ff7`f7cb9980 (System.IO.Path.GetFileName(System.String), mdToken: 0000000006001916) 00007ff7`f8be7ede 488bf0 mov rsi,rax // rsi == GetFileName(filePath) ...[생략]... 00007ff7`f8be8011 488b5590 mov rdx,qword ptr [rbp-70h] 00007ff7`f8be8015 48b9384ab5f8f77f0000 mov rcx,7FF7F8B54A38h (MT: TestApp.Thumbnail+IExtractImage) 00007ff7`f8be801f e8acbe2b5e call clr!JIT_ChkCastInterface (00007ff8`56ea3ed0) <span style='color: blue; font-weight: bold'>00007ff7`f8be8024 488bf0 mov rsi,rax // extractImage = (Thumbnail.IExtractImage)objectValue; // rsi == extractImage </span>...[생략]... 00007ff7`f8be80ed e8eeb3e5fe call 00007ff7`f7a434e0 (System.IntPtr.op_Inequality(IntPtr, IntPtr), mdToken: 0000000006000f99) 00007ff7`f8be80f2 84c0 test al,al 00007ff7`f8be80f4 7409 je 00007ff7`f8be80ff 00007ff7`f8be80f6 488b4db8 mov rcx,qword ptr [rbp-48h] <span style='color: blue; font-weight: bold'>00007ff7`f8be80fa e8e15396ff call 00007ff7`f854d4e0 (System.Runtime.InteropServices.Marshal.FreeHGlobal(IntPtr), mdToken: 000000000600600b) // crash 발생한 코드 >>> 00007ff7`f8be80ff 488b4db8 mov rcx,qword ptr [rbp-48h] </span>00007ff7`f8be8103 33d2 xor edx,edx ...[생략]... 00007ff7`f8be8199 5f pop rdi 00007ff7`f8be819a 415e pop r14 00007ff7`f8be819c 5d pop rbp 00007ff7`f8be819d c3 ret </pre> <br /> 하지만, 보는 바와 같이 GetThumbnailImage에 전달된 filePath인자는 rdx에 전달되어 rsi에만 보관되고 스택 상에는 백업되지 않고 있습니다. 게다가 문제가 발생하는 FreeHGlobal 시점에는 rsi가 Thumbnail.IExtractImage 인스턴스의 값을 갖게 되므로 일단 이 frame에서는 filePath 인자 값을 알 방법이 없습니다.<br /> <br /> 그렇다면, 이제 GetThumbnailImage를 호출한 측에서 구할 수 있는지 희망을 가져보겠습니다. ^^<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > <span style='color: blue; font-weight: bold'>0000007e357be690 00007ff7f8bdaf82</span> TestApp.FileInfoApi.Page_Load(System.Object, System.EventArgs) </pre> <br /> Reflection을 통해 .NET 코드를 먼저 보면,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > protected override void Page_Load(object sender, EventArgs e) { base.Page_Load(<span style='color: blue; font-weight: bold'>RuntimeHelpers.GetObjectValue</span>(sender), e); // ...[생략]... <span style='color: blue; font-weight: bold'>Hashtable hashtable</span> = GetEnvInfo(); // ...[생략]... <span style='color: blue; font-weight: bold'>string text = ConfigurationManager.AppSettings["FileDir"] + Conversions.ToString(hashtable["PATH"]);</span> // ...[생략]... Thumbnail thumbnail = new Thumbnail(); Image image = null; try { image = thumbnail.GetThumbnailImage(<span style='color: blue; font-weight: bold'>text</span>, 260); } catch (Exception ex) { image = null; } // ...[생략]... } </pre> <br /> RuntimeHelpers.GetObjectValue 코드가 있는 것을 보니 애당초 소스 코드가 VB.NET으로 만들어졌음을 짐작게 합니다. 그리고 GetThumbnailImage에 전달한 첫 번째 인자의 값이 "ConfigurationManager.AppSettings["FileDir"] + Conversions.ToString(hashtable["PATH"])"라는 것을 알 수 있고, 이쯤 해서 IP 값의 코드를 덤프해 함께 보면,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > 0:040> <span style='color: blue; font-weight: bold'>!u 00007ff7f8bdaf82</span> Normal JIT generated code TestApp.FileInfoApi.Page_Load(System.Object, System.EventArgs) Begin 00007ff7f8bdac40, size 523 00007ff7`f8bdac40 55 push rbp 00007ff7`f8bdac41 57 push rdi 00007ff7`f8bdac42 56 push rsi 00007ff7`f8bdac43 53 push rbx 00007ff7`f8bdac44 4881ec98000000 sub rsp,98h 00007ff7`f8bdac4b c5f877 vzeroupper <span style='color: blue; font-weight: bold'>00007ff7`f8bdac4e 488dac24b0000000 lea rbp,[rsp+0B0h]</span> 00007ff7`f8bdac56 488bf1 mov rsi,rcx 00007ff7`f8bdac59 488d7dc8 lea rdi,[rbp-38h] 00007ff7`f8bdac5d b908000000 mov ecx,8 00007ff7`f8bdac62 33c0 xor eax,eax 00007ff7`f8bdac64 f3ab rep stos dword ptr [rdi] 00007ff7`f8bdac66 488bce mov rcx,rsi 00007ff7`f8bdac69 4889a570ffffff mov qword ptr [rbp-90h],rsp 00007ff7`f8bdac70 48894d10 mov qword ptr [rbp+10h],rcx 00007ff7`f8bdac74 498bf0 mov rsi,r8 00007ff7`f8bdac77 488bca mov rcx,rdx 00007ff7`f8bdac7a e8e1c8a75e call clr!ObjectNative::GetObjectValue (00007ff8`57657560) 00007ff7`f8bdac7f 488bd0 mov rdx,rax 00007ff7`f8bdac82 488b4d10 mov rcx,qword ptr [rbp+10h] 00007ff7`f8bdac86 4c8bc6 mov r8,rsi 00007ff7`f8bdac89 e8726e69ff call 00007ff7`f8271b00 (TestApp.BasePage.Page_Load(System.Object, System.EventArgs), mdToken: 00000000060001af) // ...[생략]... <span style='color: blue; font-weight: bold'>00007ff7`f8bdad7f ff5028 call qword ptr [rax+28h] (TestApp.GetEnvInfo(), mdToken: 0000000006000231) 00007ff7`f8bdad82 48894588 mov qword ptr [rbp-78h],rax </span>00007ff7`f8bdad86 488b4d88 mov rcx,qword ptr [rbp-78h] // ...[생략]... 00007ff7`f8bdae42 e8e9e690ff call 00007ff7`f84e9530 (System.Configuration.ConfigurationManager.get_AppSettings(), mdToken: 0000000006000234) 00007ff7`f8bdae47 488bc8 mov rcx,rax // rcx == ConfigurationManager.AppSettings 00007ff7`f8bdae4a 48ba08aa436c5c020000 mov rdx,25C6C43AA08h 00007ff7`f8bdae54 488b12 mov rdx,qword ptr [rdx] // rdx == "FileDir" 00007ff7`f8bdae57 3909 cmp dword ptr [rcx],ecx 00007ff7`f8bdae59 e8528511ff call 00007ff7`f7cf33b0 (System.Collections.Specialized.NameValueCollection.get_Item(System.String), mdToken: 0000000006002354) 00007ff7`f8bdae5e 488bf0 mov rsi,rax 00007ff7`f8bdae61 48bad89a436c5c020000 mov rdx,25C6C439AD8h 00007ff7`f8bdae6b 488b12 mov rdx,qword ptr [rdx] // rdx == "PATH" 00007ff7`f8bdae6e 488b4d88 mov rcx,qword ptr [rbp-78h] // rcx == hashtable 00007ff7`f8bdae72 488b4588 mov rax,qword ptr [rbp-78h] // 00007ff7`f8bdae76 488b00 mov rax,qword ptr [rax] 00007ff7`f8bdae79 488b4048 mov rax,qword ptr [rax+48h] 00007ff7`f8bdae7d ff5020 call qword ptr [rax+20h] 00007ff7`f8bdae80 488bc8 mov rcx,rax 00007ff7`f8bdae83 e8e80b90ff call 00007ff7`f84dba70 (Microsoft.VisualBasic.CompilerServices.Conversions.ToString(System.Object), mdToken: 00000000060003bf) 00007ff7`f8bdae88 488bd0 mov rdx,rax 00007ff7`f8bdae8b 488bce mov rcx,rsi 00007ff7`f8bdae8e e83dd0e4fe call 00007ff7`f7a27ed0 (System.String.Concat(System.String, System.String), mdToken: 0000000006000555) 00007ff7`f8bdae93 488bf0 mov rsi,rax // string text = ConfigurationManager.AppSettings["FileDir"] + Conversions.ToString(hashtable["PATH"]); ...[생략]... 00007ff7`f8bdaf66 488bf8 mov rdi,rax 00007ff7`f8bdaf69 488bcf mov rcx,rdi 00007ff7`f8bdaf6c e8e77bdeff call 00007ff7`f89c2b58 (TestApp.Thumbnail..ctor(), mdToken: 000000000600039e) 00007ff7`f8bdaf71 488bcf mov rcx,rdi <span style='color: blue; font-weight: bold'>00007ff7`f8bdaf74 488bd6 mov rdx,rsi // rsi == filePath</span> 00007ff7`f8bdaf77 41b804010000 mov r8d,104h <span style='color: blue; font-weight: bold'>00007ff7`f8bdaf7d e8de7bdeff call 00007ff7`f89c2b60 (TestApp.Thumbnail.GetThumbnailImage(System.String, Int32), mdToken: 000000000600039f) >>> 00007ff7`f8bdaf82 48894580 mov qword ptr [rbp-80h],rax</span> 00007ff7`f8bdaf86 488b4d80 mov rcx,qword ptr [rbp-80h] 00007ff7`f8bdaf8a e801a992ff call 00007ff7`f8505890 (Microsoft.VisualBasic.Information.IsNothing(System.Object), mdToken: 0000000006000130) ...[생략]... 00007ff7`f8bdb15f 5e pop rsi 00007ff7`f8bdb160 5f pop rdi 00007ff7`f8bdb161 5d pop rbp 00007ff7`f8bdb162 c3 ret </pre> <br /> 코드 분석을 GetThumbnailImage가 호출된 곳부터 위로 올라가면서 분석을 진행하면 됩니다. 우선, 아래의 코드를 통해 rsi로부터 filePath가 rdx에 대입되어 GetThumbnailImage에 넘어온 것을 확인할 수 있습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > 00007ff7`f8bdaf74 488bd6 <span style='color: blue; font-weight: bold'>mov rdx,rsi</span> // rsi == filePath</span> 00007ff7`f8bdaf77 41b804010000 mov r8d,104h 00007ff7`f8bdaf7d e8de7bdeff call 00007ff7`f89c2b60 (TestApp.Thumbnail.GetThumbnailImage(System.String, Int32), mdToken: 000000000600039f) </pre> <br /> 이어서 rsi에 마지막으로 값이 대입된 곳을 보면,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > 00007ff7`f8bdae61 48bad89a436c5c020000 mov rdx,25C6C439AD8h 00007ff7`f8bdae6b 488b12 mov rdx,qword ptr [rdx] // rdx == "PATH" 00007ff7`f8bdae6e 488b4d88 <span style='color: blue; font-weight: bold'>mov rcx,qword ptr [rbp-78h]</span> // rcx == hashtable 00007ff7`f8bdae72 488b4588 mov rax,qword ptr [rbp-78h] 00007ff7`f8bdae76 488b00 mov rax,qword ptr [rax] 00007ff7`f8bdae79 488b4048 mov rax,qword ptr [rax+48h] 00007ff7`f8bdae7d ff5020 call qword ptr [rax+20h] // System.Collections.Hashtable 타입의 indexer 메서드 호출 00007ff7`f8bdae80 488bc8 mov rcx,rax 00007ff7`f8bdae83 e8e80b90ff call 00007ff7`f84dba70 (Microsoft.VisualBasic.CompilerServices.Conversions.ToString(System.Object), mdToken: 00000000060003bf) 00007ff7`f8bdae88 488bd0 mov rdx,rax 00007ff7`f8bdae8b 488bce mov rcx,rsi 00007ff7`f8bdae8e e83dd0e4fe call 00007ff7`f7a27ed0 (System.String.Concat(System.String, System.String), mdToken: 0000000006000555) 00007ff7`f8bdae93 488bf0 <span style='color: blue; font-weight: bold'>mov rsi,rax</span> // string text = ConfigurationManager.AppSettings["FileDir"] + Conversions.ToString(hashtable["PATH"]); </pre> <br /> 결국 마지막으로 "mov rsi, rax"에 의해 "ConfigurationManager.AppSettings["FileDir"] + Conversions.ToString(hashtable["PATH"])" 값이 rsi에 들어갔으며, 좀 더 위를 보면 운이 좋게도 hashtable 인스턴스의 값이 "[rbp - 78h]" 스택 위치에 들어있습니다.<br /> <br /> 이 시점의 rbp 값을 얻기 위해서는 함수의 prologue 코드를 봐야 하는데,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > 00007ff7`f8bdac40 55 push rbp 00007ff7`f8bdac41 57 push rdi 00007ff7`f8bdac42 56 push rsi 00007ff7`f8bdac43 53 push rbx 00007ff7`f8bdac44 4881ec98000000 sub rsp,98h 00007ff7`f8bdac4b c5f877 vzeroupper <span style='color: blue; font-weight: bold'>00007ff7`f8bdac4e 488dac24b0000000 lea rbp,[rsp+0B0h]</span> </pre> <br /> 저 시점의 "rsp" 값은 "!callstack"에서 확인한 "Child SP" 값이므로,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > <span style='color: blue; font-weight: bold'>0000007e357be690</span> 00007ff7f8bdaf82 TestApp.FileInfoApi.Page_Load(System.Object, System.EventArgs) </pre> <br /> 따라서 다음과 같이 hashtable 인스턴스를 알아낼 수 있습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > 0:040> <span style='color: blue; font-weight: bold'>dq 0000007e357be690+0xb0-0x78 L1</span> 0000007e`357be6c8 <span style='color: blue; font-weight: bold'>0000025a`7a9c60e8</span> 0:040> <span style='color: blue; font-weight: bold'>!do 0000025a`7a9c60e8</span> Name: System.Collections.Hashtable MethodTable: 00007ff7f7b4a710 EEClass: 00007ff7f7b70c28 Size: 80(0x50) bytes File: C:\Windows\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll Fields: MT Field Offset Type VT Attr Value Name 00007ff7f7c13b38 400182e 8 ...ashtable+bucket[] 0 instance <span style='color: blue; font-weight: bold'>0000025a7a9c77b8 buckets</span> 00007ff7f79efe60 400182f 30 System.Int32 1 instance 44 count 00007ff7f79efe60 4001830 34 System.Int32 1 instance 22 occupancy 00007ff7f79efe60 4001831 38 System.Int32 1 instance 64 loadsize 00007ff7f7aa1d60 4001832 3c System.Single 1 instance 0.720000 loadFactor 00007ff7f79efe60 4001833 40 System.Int32 1 instance 48 version 00007ff7f79ecd90 4001834 44 System.Boolean 1 instance 0 isWriterInProgress 00007ff7f79eab78 4001835 10 ...tions.ICollection 0 instance 0000000000000000 keys 00007ff7f79eab78 4001836 18 ...tions.ICollection 0 instance 0000000000000000 values 00007ff7f7ac1a30 4001837 20 ...IEqualityComparer 0 instance 0000000000000000 _keycomparer 00007ff7f79e6800 4001838 28 System.Object 0 instance 0000000000000000 _syncRoot </pre> <br /> System.Collections.Hashtable+bucket[] 타입의 buckets 속성을 다음의 글을 통해 덤프해 보면,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > windbg/sos - Hashtable의 buckets 배열 내용을 모두 덤프하는 방법 (do_hashtable.py) ; <a target='tab' href='http://www.sysnet.pe.kr/2/0/12084'>http://www.sysnet.pe.kr/2/0/12084</a> </pre> <br /> hashtable["PATH"]에는 "myfile.xlsm" 값이 들어 있음을 확인할 수 있습니다. 그럼, ConfigurationManager.AppSettings["FileDir"]에는 어떤 값이 들어 있을까요? 아쉽게도 이 값은 스택에 보관된 적이 없어 조회가 안 됩니다. 단지, 운이 좋다면 이미 해제/할당을 반복한 스택 공간에서 저 값을 발견할 수도 있으므로 "!dso" 명령을 내려 보면,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > 0:040> <span style='color: blue; font-weight: bold'>!dso</span> OS Thread Id: 0x20cc (40) RSP/REG Object Name rbx 0000025a7a9aaa38 TestApp.FileExt <span style='color: blue; font-weight: bold'>rsi 0000025a7aa07808 System.String c:\temp\myfile.xlsm</span> rdi 0000025a7aa0a3e0 TestApp.Thumbnail 0000007E357BE438 0000025a7aa0a6c0 System.Object 0000007E357BE528 0000025a7aa0a3e0 TestApp.Thumbnail ...[생략]... 0000007E357BE5A0 0000025a7aa0a6d8 System.__ComObject 0000007E357BE5A8 0000025a7aa0a618 System.String myfile.xlsm ...[생략]... <span style='color: blue; font-weight: bold'>0000007E357BE668 0000025a7aa07808 System.String c:\temp\myfile.xlsm</span> 0000007E357BE670 0000025a7aa0a3e0 TestApp.Thumbnail 0000007E357BE690 0000025a7a9aaa38 TestApp.FileExt ...[생략]... </pre> <br /> "myfile.xlsm"이 hashtable["PATH"]였으므로 "c:\temp\myfile.xlsm" 문자열을 통해 ConfigurationManager.AppSettings["FileDir"] == "c:\temp\" 였음을 유추할 수 있습니다. (물론, 100% 확신해서는 안 됩니다.)<br /> <br /> windbg에서의 분석은 일단 여기서 끝이 납니다.<br /> <br /> <hr style='width: 50%' /><br /> <br /> 자, 그럼 도대체 myfile.xlsm으로 GetThumbnailImage 함수가 호출될 때 어떤 일이 벌어지는 걸까요? 재현을 위해 웹상에서 예제 파일을 하나 구하고,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > https://www.exceltip.com/wp-content/uploads/2015/06/Download-Sample-File-xlsm.xlsm </pre> <br /> 제 컴퓨터에는 Office가 설치되어 있으므로 이 값을 입력으로 해보면 현상이 나올 듯한데요. 실제로 GetLocation 메서드 수행 시,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > public Image GetThumbnailImage(string filePath, int sizepic) { // ...[생략]... intPtr2 = Marshal.AllocHGlobal(1024); // ...[생략]... <span style='color: blue; font-weight: bold'>extractImage2.GetLocation(pszPathBuffer, 1024, ref num, ref size, 32, ref num3);</span> /* GetLocation 호출 시 예외 발생 Critical error detected c0000374 ConsoleApp1.exe has triggered a breakpoint. */ extractImage.Extract(ref hbitmap); // ...[생략]... } </pre> <br /> 예외가 발생하는 것을 볼 수 있습니다. 그리고 IExtractImage 인터페이스의 GetLocation 함수 원형을 보면 예외의 원인이 나옵니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > IExtractImage::GetLocation method ; <a target='tab' href='https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-iextractimage-getlocation'>https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-iextractimage-getlocation</a> HRESULT GetLocation(<span style='color: blue; font-weight: bold'>LPWSTR pszPathBuffer, DWORD cch</span>, DWORD *pdwPriority, const SIZE *prgSize, DWORD dwRecClrDepth, DWORD *pdwFlags); </pre> <br /> 첫 번째 인자의 타입이 "LPWSTR"이므로 GetLocation 함수는 입력된 버퍼에 대해 "글자 하나" 당 2바이트로 취급하게 되는데 "intPtr2 = Marshal.AllocHGlobal(1024)" 코드에서 1024 바이트를 할당해 두었지만 "GetLocation(pszPathBuffer, 1024, ...)"와 같이 pszPathBuffer의 크기가 1024개라고 지정했으므로 내부적으로는 2048 바이트로 취급하게 되는 것입니다.<br /> <br /> 이로 인해 버퍼 오버플로우가 발생했고 운이 나쁘게도 하필 ODAC에서,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > windbg - CoTaskMemFree/FreeCoTaskMem에서 발생한 덤프 분석 사례 - 두 번째 이야기 ; <a target='tab' href='http://www.sysnet.pe.kr/2/0/12062'>http://www.sysnet.pe.kr/2/0/12062</a> </pre> <br /> 누명을 썼던 것입니다. ^^; 문제를 수정하는 방법은 간단합니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > [버퍼를 그에 맞게 늘려주거나] intPtr2 = Marshal.AllocHGlobal(1024 * sizeof(char)); // C++의 경우 1024 * sizeof(wchar_t) [절반만 전달하거나] extractImage2.GetLocation(pszPathBuffer, 1024 / sizeof(char), ref num, ref size, 32, ref num3); </pre> <br /> 휴~~~ 이것으로 기나긴 분석이 끝났습니다. ^^<br /> <br /> <hr style='width: 50%' /><br /> <br /> 그런데, 도대체 저 이상한 VB.NET 코드는 어떻게 탄생한 것일까요? 제 생각에는 고객사 측의 개발자가 직접 만들었을 것 같지는 않고 아마도 인터넷을 참고했을 거라는 생각에 유사하게 검색을 했더니 다음의 글이 나옵니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > Load Images in form Like the opendialog does vb.net ; <a target='tab' href='https://www.experts-exchange.com/questions/27755159/Load-Images-in-form-Like-the-opendialog-does-vb-net.html'>https://www.experts-exchange.com/questions/27755159/Load-Images-in-form-Like-the-opendialog-does-vb-net.html</a> </pre> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > Dim flags = IEIFLAG.ORIGSIZE ' Or IEIFLAG.QUALITY Dim bmp As IntPtr <span style='color: blue; font-weight: bold'>Dim thePath = Marshal.AllocHGlobal(MAX_PATH)</span> 'Interop will throw an exception if one of these calls fail. Try extract.<span style='color: blue; font-weight: bold'>GetLocation</span>(thePath, <span style='color: blue; font-weight: bold'>MAX_PATH</span>, 0, size, colorDepth, flags) extract.Extract(bmp) Catch ex As Exception End Try </pre> <br /> 하필 저 코드를 참조한 개발자도 어지간히 운이 없었던 것입니다. 왜냐하면 유사한 제목의 다른 코드들은 대부분 이렇게 작성되어 있는데,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > Int32 GetLocation([MarshalAs(UnmanagedType.<span style='color: blue; font-weight: bold'>LPWStr</span>)] out StringBuilder pszPathBuffer, int cch, ...); </pre> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > StringBuilder location = new StringBuilder(260, 260); extractImage.GetLocation(out location, location.Capacity, ...); </pre> <br /> LPWStr로 마샬링되는 StringBuilder는 문자 당 2바이트를 점유하게 되므로 힙을 망가뜨리는 일이 없습니다.<br /> <br /> <hr style='width: 50%' /><br /> <br /> 자, 그럼 마지막 의문입니다. 왜 해당 웹 응용 프로그램은 GetThumbnailImage 메서드를 호출할 때마다 "무조건" 비정상 종료하지 않고 버텼을까요?<br /> <br /> 그건 IExtractImage::GetLocation을 구현하는 것이 또 다른 응용 프로그램 개발자가 임의 재량으로 구현할 수 있는 여지가 있기 때문입니다. 예를 들어, 자신만의 확장자를 정의해 다루는 응용 프로그램을 개발하는 경우, 개발자는 그 확장자에 대한 Thumbnail을 제공하도록 "IExtractImage" COM 객체를 구현한 전용 모듈을 제공할 수 있습니다. 그렇다면 당연히 IExtractImage::GetLocation 함수의 내부 구현은 개발자마다 다를 수 있다는 결론을 얻을 수 있습니다.<br /> <br /> 아마도 다음과 같이 다양하게 구현되었을 것입니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > // 1번 유형: 미처 구현을 안 했을 수도 있고, HRESULT IExtractImage::GetLocation(LPWSTR pszPathBuffer, DWORD cch, DWORD *pdwPriority, const SIZE *prgSize, DWORD dwRecClrDepth, DWORD *pdwFlags) { return E_NOTIMPL; } </pre> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > // 2번 유형: 일단 전체 버퍼를 초기화하고 시작했을 수도 있고, HRESULT IExtractImage::GetLocation(LPWSTR pszPathBuffer, DWORD cch, DWORD *pdwPriority, const SIZE *prgSize, DWORD dwRecClrDepth, DWORD *pdwFlags) { memset(pszPathBuffer, 0, cch * sizeof(wchar_t)); // ...[생략]... } </pre> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > // 3번 유형: 단일 null 초기화만 했을 수도 있고, HRESULT IExtractImage::GetLocation(LPWSTR pszPathBuffer, DWORD cch, DWORD *pdwPriority, const SIZE *prgSize, DWORD dwRecClrDepth, DWORD *pdwFlags) { *pszPathBuffer = L'\0'; // ...[생략]... } </pre> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > // 4번 유형: 프로그램에서 필요한 만큼의 버퍼만 사용했을 수도 있고, HRESULT IExtractImage::GetLocation(LPWSTR pszPathBuffer, DWORD cch, DWORD *pdwPriority, const SIZE *prgSize, DWORD dwRecClrDepth, DWORD *pdwFlags) { wchar_t *pszFilePath = GetFilePath(); wcscpy(pszPathBuffer, pszFilePath); // ...[생략]... } </pre> <br /> 그리고 고객사의 덤프가 발생했던 시점의 환경에서 사용한 "xlsm 확장자"의 경우에는 IExtractImage::GetLocation의 구현체가 (테스트해 보면) 2번 유형에 해당한다는 것을 알 수 있습니다.<br /> <br /> <hr style='width: 50%' /><br /> <br /> 한 가지 아쉬운 점이 있다면, 이럴 거면 gflags.exe의 heap 관련 옵션을 실습해 볼 걸 그랬습니다. ^^<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > GFlags and PageHeap ; <a target='tab' href='https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/gflags-and-pageheap'>https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/gflags-and-pageheap</a> </pre> <br /> 아마도 저걸로 했으면 ODAC 사용 당시에도 IExtractImage::GetLocation에서 높은 확률로 예외가 발생했을 듯합니다.<br /> </p><br /> <br /><hr /><span style='color: Maroon'>[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]</span> </div>
첨부파일
스팸 방지용 인증 번호
7329
(왼쪽의 숫자를 입력해야 합니다.)