Microsoft MVP성태의 닷넷 이야기
디버깅 기술: 197. Windbg - PE 포맷의 Export Directory 탐색 [링크 복사], [링크+제목 복사],
조회: 4471
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일
 

(시리즈 글이 4개 있습니다.)
.NET Framework: 872. C# - 로딩된 Native DLL의 export 함수 목록 출력
; https://www.sysnet.pe.kr/2/0/12093

디버깅 기술: 197. Windbg - PE 포맷의 Export Directory 탐색
; https://www.sysnet.pe.kr/2/0/13689

디버깅 기술: 199. Windbg - 리눅스에서 뜬 닷넷 응용 프로그램 덤프 파일에 포함된 DLL의 Export Directory 탐색
; https://www.sysnet.pe.kr/2/0/13692

디버깅 기술: 200. DLL Export/Import의 Hint 의미
; https://www.sysnet.pe.kr/2/0/13700




Windbg - PE 포맷의 Export Directory 탐색

예전에 C#으로 Export DataDirecotry에서 함수 이름을 출력하는 방법을 다뤘었는데요,

C# - 로딩된 Native DLL의 export 함수 목록 출력
; https://www.sysnet.pe.kr/2/0/12093

이번에는 windbg를 이용해 Export 영역 전체를 다뤄보겠습니다. ^^ 이를 위해 Visual Studio에서 C/C++ 기본 DLL 프로젝트를 만들고, 다음의 2개 API만 export한 다음,

// 헤더 파일

#ifdef DLL1_EXPORTS
#define DLL1_API __declspec(dllexport)
#else
#define DLL1_API __declspec(dllimport)
#endif

extern "C"
{
    DLL1_API int fnDll1(void);

    DLL1_API int fnDll2(void);
}

// 구현 파일

DLL1_API int fnDll1(void)
{
    return 0;
}

DLL1_API int fnDll2(void)
{
    return 0;
}

일부러 ordinal도 함께 주기 위해 "Source.def" 파일도 추가합니다.

LIBRARY
EXPORTS
    fnDll1 @100
    fnDll2 @103

이렇게 만든 DLL 함수를 호출하는 EXE를 실행해 windbg로 붙입니다. 그럼, lm 명령어를 이용해 모듈을 나열할 수 있고,

0:004> lm
start             end                 module name
00007ff7`ef0f0000 00007ff7`ef115000   ConsoleApplication1   (deferred)             
00007ff9`b4a80000 00007ff9`b4ca1000   ucrtbased   (deferred)             
00007ffa`6ca90000 00007ffa`6cab6000   Dll1       (deferred)     
...[생략]... 

로딩 주소(00007ffa`559a0000)를 시작으로 dh 명령어를 내리면 어느 정도의 PE 정보가 나옵니다.

0:004> !dh 00007ffa`6ca90000

File Type: DLL
FILE HEADER VALUES
    8664 machine (X64)
       A number of sections
66961A76 time date stamp Tue Jul 16 16:00:06 2024

       0 file pointer to symbol table
       0 number of symbols
      F0 size of optional header
    2022 characteristics
            Executable
            App can handle >2gb addresses
            DLL

OPTIONAL HEADER VALUES
     20B magic #
   14.40 linker version
    8200 size of code
    7400 size of initialized data
       0 size of uninitialized data
   1100A address of entry point
    1000 base of code
         ----- new -----
00007ffa6ca90000 image base
    1000 section alignment
     200 file alignment
       2 subsystem (Windows GUI)
    6.00 operating system version
    0.00 image version
    6.00 subsystem version
   26000 size of image
     400 size of headers
       0 checksum
0000000000100000 size of stack reserve
0000000000001000 size of stack commit
0000000000100000 size of heap reserve
0000000000001000 size of heap commit
     160  DLL characteristics
            High entropy VA supported
            Dynamic base
            NX compatible
   1CB70 [     16D] address [size] of Export Directory
   21320 [      50] address [size] of Import Directory
   24000 [     326] address [size] of Resource Directory
   1E000 [    1D70] address [size] of Exception Directory
       0 [       0] address [size] of Security Directory
   25000 [      64] address [size] of Base Relocation Directory
   1B510 [      38] address [size] of Debug Directory
       0 [       0] address [size] of Description Directory
       0 [       0] address [size] of Special Directory
       0 [       0] address [size] of Thread Storage Directory
   1B390 [     140] address [size] of Load Configuration Directory
       0 [       0] address [size] of Bound Import Directory
   21000 [     320] address [size] of Import Address Table Directory
       0 [       0] address [size] of Delay Import Directory
       0 [       0] address [size] of COR20 Header Directory
       0 [       0] address [size] of Reserved Directory

...[생략]...
SECTION HEADER #3
  .rdata name
    2CDD virtual size
   1A000 virtual address
    2E00 size of raw data
    8600 file pointer to raw data
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
40000040 flags
         Initialized Data
         (no align specified)
         Read Only
...[생략]...

그중에 Export Directory를 보면, VirtualAddress == 0x1CB70, Size == 0x16D로 나오는데요, 이 정보를 이용해 다음과 같이 덤프할 수 있습니다.

// VirtualAddress가 가리키는 IMAGE_EXPORT_DIRECTORY의 크기가 40바이트이므로 4로 나눈 10(0xA) 개만 덤프

0:004> dd 00007ffa`6ca90000 + 1CB70 LA
00007ffa`6caacb70  00000000 ffffffff 00000000 0001cbb4
00007ffa`6caacb80  00000064 00000004 00000002 0001cb98
00007ffa`6caacb90  0001cba8 0001cbb0

이것은 다음과 같이 매핑이 됩니다.

[StructLayout(LayoutKind.Sequential)]
public struct IMAGE_EXPORT_DIRECTORY
{
    public uint Characteristics; // 00000000
    public uint TimeDateStamp;   // ffffffff
    public short MajorVersion;   // 0000
    public short MinorVersion;   // 0000
    public uint Name;            // 0001cbb4
    public uint Base;            // 00000064
    public uint NumberOfFunctions;     // 00000004
    public uint NumberOfNames;         // 00000002
    public uint AddressOfFunctions;    // 0001cb98
    public uint AddressOfNames;        // 0001cba8
    public uint AddressOfNameOrdinals; // 0001cbb0
}

각각의 필드는 다음과 같은 의미를 갖는데요,

[출처: PE Internals Part 1: A few words about Export Address Table (EAT)]
(그림의 구조체 필드 값과 본문의 필드 값은 상관없으니, 그냥 구조만 참고하시면 됩니다.)
pe_export_table_1.png

NumberOfFunctions가 4개로 나오는 것은 Ordinal 때문입니다. @100 ~ @103까지 지정했기 때문에 100, 101, 102, 103의 Ordinal 공간이 생성되는데, 중간에 101, 102번은 없지만 그래도 4개 공간이 확보됩니다. (사실, 이름에서 좀 혼란이 오는데, 그냥 # of ordinals 의미로 이해하면 됩니다.)

4개(실질적으로는 2개)의 함수 위치를 찾으려면 AddressOfFunctions(0x0001cb98)로부터 sizeof(DWORD) * NumberOfFunctions 만큼의 공간을 덤프하면 되는데,

// NumberOfFunctions == 4개이므로!

0:004> dd 00007ffa`6ca90000+0001cb98 L4
00007ffa`6caacb98  00011122 00000000 00000000 00011050

각각 이렇게 해석이 됩니다.

// 이 값의 테이블을 Export Address Table이라고 합니다.

[0] == 00011122
[1] == 00000000
[2] == 00000000
[3] == 00011050

그런데, 엄밀히 따지면 Oridnal이 100번이므로 앞서 사용되지 않는 0 ~ 99번까지의 슬롯 공간도 AddressOfFunctions에 있어야 합니다. 만약 그랬다면 쓸데없는 공간을 더 낭비하게 되는데요, 바로 이런 문제를 없애는 필드가 IMAGE_EXPORT_DIRECTORY.Base입니다. 위의 경우 Base 값이 100(0x64)인데요, 따라서 기본적으로 100을 시작으로 AddressOfFunctions를 구성할 수 있게 돼 공간을 절약하는 효과를 가집니다.

혹시 Ordinal을 이런 식으로 주면 어떻게 될까요?

LIBRARY
EXPORTS
    fnDll1 @100
    fnDll2 @1500

예상할 수 있듯이, 시작 번호는 Base 필드를 이용해 절약할 수 있지만 그 이후의 빈 공간은 그대로 점유하게 됩니다. 따라서 저렇게 정의하면 NumberOfFunctions는 0x579(1401)가 되고, 그중 [0], [1400]만 사용하고 그 사이의 공간은 모두 0으로 채워집니다. (이런 것도 한때는 공간 낭비였겠지만, 근래의 컴퓨팅 환경에서는 큰 문제가 되지는 않을 것입니다. ^^;)

다시 본론으로 돌아와서, 저 EAT에 있는 개별 값들은 각각 RVA 값입니다. 저 주소는 실제 구현 코드를 담고 있는 것이 아니라, 그 구현 코드로의 jmp 문이 있는 코드의 주소인데, 각각의 EAT 주소를 계산해 u(nassembly) 해보면,

0:004> u 00007ffa`6ca90000 + 00011122 L1
Dll1!ILT+285(?fnDll1YAHXZ):
00007ffa`6caa1122 e999050000      jmp     Dll1!fnDll1 (00007ffa`6caa16c0)

0:004> u 00007ffa`6ca90000 + 00011050 L1
Dll1!ILT+75(?fnDll2YAHXZ):
00007ffa`6caa1050 e9bb060000      jmp     Dll1!fnDll2 (00007ffa`6caa1710)

실제 구현 코드로 점프하는 코드가 나오고 그 위치는 각각 fnDll1, fnDll2의 주소와 정확히 일치합니다.

0:004> x Dll1!fnDll1
00007ffa`6caa16c0 Dll1!fnDll1 (void)

0:004> x Dll1!fnDll2
00007ffa`6caa1710 Dll1!fnDll2 (void)




NumberOfFunctions가 빈 Ordinal까지 감안한 수였다면, NumberOfNames는 실제 이름을 갖는 함수의 수입니다. 그리고 이것은 AddressOfNames(0x0001cba8)로부터 sizeof(DWORD) * NumberOfNames 만큼의 공간을 덤프하면 되는데,

// NumberOfNames == 2이므로!

0:004> dd 00007ffa`6ca90000 + 0001cba8 L2
00007ffa`6caacba8  0001cbbd 0001cbc4

각각의 값은 함수 이름을 담고 있는 RVA이고 da 명령어로 확인할 수 있습니다.

0:004> da 00007ffa`6ca90000 + 0001cbbd
00007ffa`6caacbbd  "fnDll1"

0:004> da 00007ffa`6ca90000 + 0001cbc4
00007ffa`6caacbc4  "fnDll2"

마지막으로 AddressOfNameOrdinals(0x0001cbb0)는 sizeof(WORD) * NumberOfNames 만큼의 공간을 덤프할 수 있고,

0:004> dw 00007ffa`6ca90000+0x0001cbb0 L2
00007ffa`6caacbb0  0000 0003

0과 3은 AddressOfFunctions의 [0], [3] 인덱스에 해당합니다. 자, 그러면 대충 설명이 모두 됐습니다. 이제 저 DLL을 사용하는 측에서 각각의 함수를 호출했을 때 바인딩이 되는 절차를 짐작할 수 있습니다.

HMODULE hModule = ::LoadLibrary(L"Dll1.dll");

typedef int (*fnFunc)();
FARPROC fn = ::GetProcAddress(hModule, MAKEINTRESOURCEA(100)); // Ordinal 100번 함수
if (fn)
{
    fnFunc func = (fnFunc)fn;
    func();
}

fn = ::GetProcAddress(hModule, MAKEINTRESOURCEA(103)); // Ordinal 103번 함수
if (fn)
{
    fnFunc func = (fnFunc)fn;
    func();
}

GetProcAddress는 해당 모듈의 AddressOfFunctions 위치를 구하고, 사용자가 전달한 값 100, 103에서 IMAGE_EXPORT_DIRECTORY.Base (100, 0x64) 값만큼을 뺀 AddressOfFunctions[0], AddressOfFunctions[3] 주솟값을 반환하는 역할을 합니다.

그럼, Ordinal이 아닌 함수 이름으로 찾으면 어떻게 될까요?

FARPROC fn = ::GetProcAddress(hModule, "fnDll1");

fn = ::GetProcAddress(hModule, "fnDll2");

"fnDll1" 문자열을 AddressOfNames에서 찾아, 있으면 그것의 값을 찾게 된 인덱스, 위의 경우에는 첫 번째 항목에서 찾았으므로 0이 되는데요, 그것으로 다시 AddressOfNameOrdinals[0] 값을 읽어냅니다. 그 값에는 0이 있기 때문에 AddressOfFunctions[0]에 해당하는 주소를 반환하게 됩니다.

반면, "fnDll2" 문자열을 AddressOfNames에서 찾으면 1번째 위치에서 발견하게 되는데요, 그것으로 AddressOfNameOrdinals[1]에 해당하는 값을 읽어냅니다. 거기에는 3이 있기 때문에 결국 AddressOfFunctions[3]에 해당하는 주소를 반환하게 됩니다.

단계를 보면 Ordinal로 찾는 것이 약간 더 빠른 바인딩 속도를 내는데, 단지 Ordinal보다는 함수 이름이 더 사용하기 편리하므로 보통은 Ordinal로 GetProcAddress를 하는 경우는 흔치 않습니다. 하지만 원한다면 함수 이름을 노출하지 않고 Ordinal로만 export하는 것도 가능한데요, 실제로 함수 이름을 노출하고 싶지 않은 경우, 가령 보안을 위해 일부러 그렇게 하는 사례도 있다고 합니다. (하지만 Ordinal은 DLL 업데이트에 따라 없어지는 경우도 있으므로 주의해야 합니다.)

이 외에도, (본문에서 다루지 않은) EAT에는 다른 DLL에 구현한 함수로 jmp하는 경우도 있지만 (Forwarder), 이 경우는 나중에 흥미가 생기면 다루도록 하겠습니다. ^^




마치기 전에, 다시 처음으로 돌아가 Export Directory의 값으로 구한 VirtualAddress == 0x1CB70, Size == 0x16D를 다시 볼까요?

우선 VirtualAddress는 실행 시점에는 DLL의 로딩 주소와 더하면 IMAGE_EXPORT_DIRECTORY의 위치를 알 수 있습니다. 그런데 Size는 뭘까요? 사실 IMAGE_EXPORT_DIRECTORY의 크기는 40바이트로 고정입니다. 그런데, Size == 0x16D(365) 바이트라는 값이 나옵니다.

이것은, VirtualAddress로부터 시작해 IMAGE_EXPORT_DIRECTORY, AddressOfFunctions가 가리키는 Export Address Table, AddressOfNames가 가리키는 함수 이름들, AddressOfNameOrdinals가 가리키는 Ordinal 테이블까지 모두 포함한 크기입니다. 실제로 어떤 구조로 적층이 되는지 대충 볼까요? ^^

본문의 덤프에서는,

public uint NumberOfFunctions;     // 00000004
public uint NumberOfNames;         // 00000002
public uint AddressOfFunctions;    // 0001cb98
public uint AddressOfNames;        // 0001cba8
public uint AddressOfNameOrdinals; // 0001cbb0

우선, Export Directory의 VirtualAddress는 IMAGE_EXPORT_DIRECTORY의 시작 주소이고, 이후 AddressOfFunctions의 주소가 정확히 40바이트 이후인 0001cb98에 위치하고 있습니다. 따라서 다음과 같은 구조가 됩니다.

[0x0001CB70 ~ 0x0001cb97] IMAGE_EXPORT_DIRECTORY 40바이트 (0x28)
[0x0001cb98 ~ 0x0001cba7] EAT 테이블: sizeof(DWORD) * NumberOfFunctions(4) == 16바이트

역시나 이후의 AddressOfNames의 위치가 정확히 16바이트 뒤인 0001cba8을 가리키는 것이 우연은 아닐 것입니다. ^^ 이어서 sizeof(DWORD) * NumberOfNames이므로 4 * 2 = 8바이트만큼을 점유하게 되고, 다시 예상할 수 있듯이 그 뒤를 이어 AddressOfNameOrdinals인 sizeof(WORD) * NumberOfNames이므로 2 * 2 = 4바이트가 점유합니다. 여기까지 정리하면 다음과 같은 식으로 데이터가 적층되어 나오는 구조로 이해할 수 있습니다.

[0x0001CB70 ~ 0x0001cb97] IMAGE_EXPORT_DIRECTORY 40바이트
[0x0001cb98 ~ 0x0001cba7] EAT 테이블: sizeof(DWORD) * NumberOfFunctions(4) == 16바이트
[0x0001cba8 ~ 0x0001cbaf] AddressOfNames: sizeof(DWORD) * NumberOfNames(2) == 8바이트
[0x0001cbb0 ~ 0x0001cbb3] AddressOfNameOrdinals: sizeof(WORD) * NumberOfNames(4) == 4바이트

그렇다면 그다음 데이터는 0x0001cbb4에서 시작할 텐데요, 해당 위치를 덤프하면 DLL의 이름이 나옵니다.

0:004> da 00007ffa`6ca90000+0x0001cbb4
00007ffa`6caacbb4  "Dll1.dll"

저 RVA 위치(0x0001cbb4)는 IMAGE_EXPORT_DIRECTORY 구조체의 Name 필드의 값과 일치합니다. null 문자까지 합치면 8바이트가 되는데요, 바로 그 이후의 위치가 AddressOfNames에 담긴 [0] 번째 RVA 위치에 해당합니다.

0:004> dd 00007ffa`6ca90000+0001cba8 L2
00007ffa`6caacba8  0001cbbd 0001cbc4

결국 이렇게 정리가 됩니다.

[0x0001CB70 ~ 0x0001cb97] IMAGE_EXPORT_DIRECTORY 40바이트
[0x0001cb98 ~ 0x0001cba7] AddressOfFunctions(EAT 테이블): sizeof(DWORD) * NumberOfFunctions(4) == 16바이트
[0x0001cba8 ~ 0x0001cbaf] AddressOfNames: sizeof(DWORD) * NumberOfNames(2) == 8바이트
[0x0001cbb0 ~ 0x0001cbb3] AddressOfNameOrdinals: sizeof(WORD) * NumberOfNames(4) == 4바이트
[0x0001cbb4 ~ 0x0001cbbc] "Dll1.dll" (DLL 이름) - 8바이트 (null 포함)
[0x0001cbbd ~ 0x0001cbc3] "fnDll1" (함수 이름) - 7바이트 (null 포함)
[0x0001cbc4 ~ 0x0001cbca] "fnDll2" (함수 이름) - 7바이트 (null 포함)
[0x0001cbcb ~ ...] 이후의 남은 공간은 '\0' 값만 채워진 여분의 공간

실질적인 데이터는 모두 90바이트인데 남은 (365-90=) 275바이트는 그냥 빈 여분의 공간으로 보입니다. 이렇게 Export 정보가 놓인 1CB70 주소를 "!dh ..." 명령어의 Section 정보에 찾아보면 3번 Section의 영역으로 나옵니다.

SECTION HEADER #3
  .rdata name
    2CDD virtual size
   1A000 virtual address
    2E00 size of raw data
    8600 file pointer to raw data
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
40000040 flags
         Initialized Data
         (no align specified)
         Read Only

1A000 위치로 크기는 2CDD까지 잡혀 있으니 1A000 ~ 1CCDD 영역에 1CB70 값이 들어옵니다. 그리고 1CB70 영역의 크기가 0x16D니까 정확히 끝 지점이 1CCDD가 됩니다. 그러니까, ".rdata" 섹션의 마지막에 Export 정보가 들어 있었던 것입니다. (물론, 이것은 상황에 따라 바뀔 수 있습니다.)

어쨌든 이것으로 탐색 과정은 대충 끝난 것 같습니다. ^^




참고로, 검색해 보면 이러한 Export Data Directory에 대해 다루는 내용들이 많으니 관심 있다면 둘러보는 것도 괜찮겠습니다.

Win32 reverse shellcode - pt.2 - locating the Export Directory Table
; https://xen0vas.github.io/Win32-Reverse-Shell-Shellcode-part-2-Locate-the-Export-Directory-Table

PE Internals Part 1: A few words about Export Address Table (EAT)
; https://ferreirasc.github.io/PE-Export-Address-Table/





[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 8/20/2024]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




1  2  3  4  [5]  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13739정성태9/24/20242391닷넷: 2297. C# - ssh-keygen으로 생성한 Public Key 파일 해석과 fingerprint 값(md5, sha256) 생성 [1]파일 다운로드1
13738정성태9/22/20242156C/C++: 174. C/C++ - 윈도우 운영체제에서의 file descriptor, FILE*파일 다운로드1
13737정성태9/21/20242761개발 환경 구성: 727. Visual C++ - 리눅스 프로젝트를 위한 빌드 서버의 msbuild 구성
13736정성태9/20/20242390오류 유형: 923. Visual Studio Code - Could not establish connection to "...": Port forwarding is disabled.
13735정성태9/20/20242710개발 환경 구성: 726. ARM 플랫폼용 Visual C++ 리눅스 프로젝트 빌드
13734정성태9/19/20242628개발 환경 구성: 725. ssh를 이용한 원격 docker 서비스 사용
13733정성태9/19/20242510VS.NET IDE: 194. Visual Studio - Cross Platform / "Authentication Type: Private Key"로 접속하는 방법
13732정성태9/17/20242627개발 환경 구성: 724. ARM + docker 환경에서 .NET 8 설치
13731정성태9/15/20243200개발 환경 구성: 723. C# / Visual C++ - Control Flow Guard (CFG) 활성화 [1]파일 다운로드2
13730정성태9/10/20242573오류 유형: 922. docker - RULE_APPEND failed (No such file or directory): rule in chain DOCKER
13729정성태9/9/20243195C/C++: 173. Windows / C++ - AllocConsole로 할당한 콘솔과 CRT 함수 연동 [1]파일 다운로드1
13728정성태9/7/20243093C/C++: 172. Windows - C 런타임에서 STARTUPINFO의 cbReserved2, lpReserved2 멤버를 사용하는 이유파일 다운로드1
13727정성태9/6/20243578개발 환경 구성: 722. ARM 플랫폼 빌드를 위한 미니 PC(?) - Khadas VIM4 [1]
13726정성태9/5/20243989C/C++: 171. C/C++ - 윈도우 운영체제에서의 file descriptor와 HANDLE파일 다운로드1
13725정성태9/4/20242763디버깅 기술: 201. WinDbg - sos threads 명령어 실행 시 "Failed to request ThreadStore"
13724정성태9/3/20244208닷넷: 2296. Win32/C# - 자식 프로세스로 HANDLE 상속파일 다운로드1
13723정성태9/2/20245130C/C++: 170. Windows - STARTUPINFO의 cbReserved2, lpReserved2 멤버 사용자 정의파일 다운로드2
13722정성태9/2/20242891C/C++: 169. C/C++ - CRT(C Runtime) 함수에 의존성이 없는 프로젝트 생성
13721정성태8/30/20243009C/C++: 168. Visual C++ CRT(C Runtime DLL: msvcr...dll)에 대한 의존성 제거 - 두 번째 이야기
13720정성태8/29/20242779VS.NET IDE: 193. C# - Visual Studio의 자식 프로세스 디버깅
13719정성태8/28/20243163Linux: 79. C++ - pthread_mutexattr_destroy가 없다면 메모리 누수가 발생할까요?
13718정성태8/27/20243509오류 유형: 921. Visual C++ - error C1083: Cannot open include file: 'float.h': No such file or directory [2]
13717정성태8/26/20243247VS.NET IDE: 192. Visual Studio 2022 - Windows XP / 2003용 C/C++ 프로젝트 빌드
13716정성태8/21/20243100C/C++: 167. Visual C++ - 윈도우 환경에서 _execv 동작
13715정성태8/19/20243201Linux: 78. 리눅스 C/C++ - 특정 버전의 glibc 빌드 (docker-glibc-builder)
13714정성태8/19/20243440닷넷: 2295. C# 12 - 기본 생성자(Primary constructors) (책 오타 수정) [3]
1  2  3  4  [5]  6  7  8  9  10  11  12  13  14  15  ...