수정 이력(7건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635132)
- 권한 분리 구도, 서명과 타임스탬프, 업데이트 경로 검증, DPAPI의 저장·읽기 왕복, 인증서 핀닝 판정 흐름 등을 그림으로도 따라갈 수 있도록 Mermaid 그림을 21개 추가했습니다(본문 500~750자당 1그림 규약에 맞춘 것입니다). 본문 문장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하십시오.
- 인증서를 핀닝하는 예에서, 지문이 일치하면 `SslPolicyErrors` 내용과 관계없이 `true`를 반환하던 것을 고쳤습니다. 이 방식에서는 핀닝한 인증서가 만료되어도, 키가 유출되어 해지한 뒤에도 계속 통과합니다. 인정하는 것은 `RemoteCertificateChainErrors`뿐으로 하고, 체인 상태도 `UntrustedRoot`와 `PartialChain`(사내 CA가 단말에 없음) 이외는 거부하도록 했습니다. `CheckCertificateRevocationList = true`를 추가하고, 해지 확인 수단이 없을 때 어떻게 메울지도 본문에 적었습니다.
- 각 항목의 확인 방법 절을 새로 만들었습니다(매니페스트 확인, `Get-AuthenticodeSignature`, 비밀 정보나 SQL 연결 검색어, Process Monitor 필터 조건, `dotnet list package --vulnerable`). DPAPI 예를 저장과 읽기 왕복으로 바꾸고, entropy 일치 요건과 `CurrentUser`와 `LocalMachine`의 차이를 추가했습니다. .NET 9 이후는 `ServicePointManager` 콜백이 `SocketsHttpHandler`에 매핑된다는 점을 근거로, 핸들러 단위로 대상을 한정하는 예를 추가했습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635131)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「Windows 앱 개발 보안 최소 체크리스트」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635131 https://comcomponent.com/ko/blog/windows-app-security-minimum-checklist/
- DOI(최신 버전)
- 10.5281/zenodo.21635131
- DOI(이 버전)
- 10.5281/zenodo.22217433
이 파일의 내용은 4장의 릴리스 전 체크리스트(8개 카테고리 32개 항목)와 같습니다. 다른 점은 Status와 Notes 입력란이 있다는 것과, Checklist-ja / Checklist-en 두 시트에 일본어와 영어가 모두 들어 있다는 것뿐입니다. 읽으면서 확인할 때는 4장, 리뷰 기록으로 배포할 때는 Excel판, 이렇게 나누면 충분합니다.
Windows 앱 보안이라고 하면 이야기가 갑자기 커지기 쉽습니다. 제로 트러스트, EDR, SBOM(Software Bill of Materials, 소프트웨어 구성 요소 목록), 인증서 운영, 취약성 관리. 모두 중요하지만, 실무에서는 그 전에 빠뜨리고 싶지 않은 기본이 꽤 있습니다. 특히 다음과 같은 앱에서는 「고도 방어」보다 먼저 기본의 빈틈을 막는 편이 효과가 큽니다.
- WPF / WinForms / WinUI 데스크톱 앱
- C++ / C# Win32 앱
- 장치 연동, 파일 연동, DB 연결, 사내 배포 도구
- 자동 업데이트 메커니즘을 가진 업무 앱
- Windows 서비스나 보조 EXE를 포함하는 구성
Windows 앱 개발에서는 전부를 한 번에 완벽하게 하기보다, 우선 명백히 위험한 구멍을 남기지 않는 편이 현실적입니다. 여기서는 설계, 구현, 배포, 운영의 순서로 최소한 빠뜨리고 싶지 않은 포인트를 확인하기 쉬운 형태로 정리합니다.
flowchart TB
accTitle: 이 글의 진행 방식
accDescr: 고도 방어보다 먼저 기본의 빈틈을 막는다는 방침으로, 설계·구현·배포·운영의 순서로 최소한의 포인트를 정리한다는 글의 흐름을 나타내는 그림.
adv1["고도 방어(제로 트러스트 등)"] -.->|"그 전에"| base1["기본의 빈틈을 막기"]
base1 --> o1["설계"]
o1 --> o2["구현"]
o2 --> o3["배포"]
o3 --> o4["운영"]
그림1: 고도 방어 전에 기본의 빈틈을 막는 방침으로, 설계·구현·배포·운영의 순서로 살펴봅니다.
1. 먼저 결론
- 처음에 빠뜨리고 싶지 않은 것은 불필요한 관리자 권한을 요구하지 않는 것, 서명하는 것, 비밀 정보를 평문으로 두지 않는 것, 인증서 검증을 무효화하지 않는 것입니다.
- Windows 앱은 배포물 자체가 공격 표면이 됩니다. EXE / DLL / MSI / MSIX / 자동 업데이트 모듈까지 포함해 보는 편이 안전합니다.
ServerCertificateValidationCallback => true, 평문 연결 문자열,LoadLibrary("foo.dll")의 느슨한 로드, 문자열 연결로 SQL을 실행하는 것은 최소한의 기준에서도 피해야 할 항목입니다.- 관리자 권한이 필요한 처리가 일부뿐이라면, 앱 전체를 권한 상승시키는 것이 아니라 그 부분만 별도 EXE나 service로 나누는 편이 안전합니다.
- Windows에서 배포하는 앱은 서명 + 타임스탬프를 전제로 생각하는 편이 좋습니다. 사용자에 대한 신뢰뿐 아니라 변조 탐지나 운영 설명도 쉬워집니다.
- 저장 시 기밀 정보는 용도에 따라 DPAPI / ProtectedData나 Credential Locker를 구분해 사용합니다. 적어도
appsettings.json에 평문으로 두는 상태는 벗어나고 싶습니다. - 로그는 많으면 좋은 것이 아닙니다. 토큰, 비밀번호, 연결 문자열, 개인 정보, 전체 요청 본문을 그대로 남기면 로그 자체가 사고의 주역이 됩니다.
최소한의 보안은 특수한 기능을 더하는 것보다 위험한 기본 동작이나 허술한 구현을 남기지 않는 것입니다.
flowchart TB
accTitle: 최소한의 보안에 대한 생각
accDescr: 최소한의 보안은 특수한 기능을 더하는 것이 아니라, 위험한 기본 동작이나 허술한 구현을 남기지 않는 것이라는 정리를 나타내는 그림.
add1["특수한 기능을 더하기"] -.->|"최소한의 주안점이 아님"| goal1["최소한의 보안"]
rm1["위험한 기본 동작·허술한 구현을 남기지 않기"] -->|"이것이 주안점"| goal1
그림2: 최소한의 기준은 기능 추가가 아니라, 위험한 기본 동작이나 허술한 구현을 남기지 않는 것입니다.
이 글의 지식 맵
이 글은 WPF/WinForms/WinUI/C++/C#의 Windows 업무 앱을 대상으로, 릴리스 전에 빠뜨리고 싶지 않은 최소한의 보안 대책을 체크리스트로 정리하고 있습니다. 중심이 되는 것은 앱 전체를 requireAdministrator로 하지 않고 필요한 처리만 별도 프로세스나 서비스로 분리하는 것, 배포물에 코드 서명과 타임스탬프를 붙여 미서명 배포나 인증서 검증의 상시 스킵을 피하는 것, 비밀 정보를 평문 설정에 두지 않고 DPAPI 등으로 보호하는 것입니다. 아울러 SQL의 문자열 연결이 초래하는 SQL 인젝션을 파라미터화로 방지하는 것, DLL을 이름만으로 읽으면 검색 순서 하이재킹을 부르는 것, 로그에 기밀 정보를 남기면 로그 자체가 유출 경로가 되는 것도 다루고 있습니다.
flowchart LR
accTitle: Windows 앱 보안 최소 체크리스트의 지식 맵
accDescr: 관리자 권한 처리, 코드 서명과 업데이트물 검증, 비밀 정보 보호, SQL 인젝션이나 DLL 읽기 같은 입력·실행 시 위험, 로그를 통한 기밀 유출이라는 최소한의 보안 항목들 사이의 관계를 보여주는 그림
admin_rights["관리자 권한"]
code_signing_cert["코드 서명 인증서"]
app_secrets["앱 비밀 정보"]
execution_level_asinvoker["asInvoker 실행 수준"]
execution_level_requireadministrator["requireAdministrator 실행 수준"]
desktop_app["대화형 사용자의 데스크톱 앱"]
admin_privilege_separation["관리자 권한 처리 분리"]
dll_search_order_hijacking["DLL 검색 순서 하이재킹"]
unvalidated_dll_loading["DLL 이름만으로 로드"]
unsigned_binary["서명되지 않은 바이너리"]
code_signing_timestamp["코드 서명 타임스탬프"]
certificate_expiry["인증서 만료"]
update_integrity_verification["업데이트 서명·해시 검증"]
unverified_update_application["검증되지 않은 업데이트 적용"]
certificate_pinning["인증서 피닝"]
certificate_validation_bypass["인증서 검증 상시 우회"]
certificate_revocation_check["인증서 폐기 확인"]
sql_string_concatenation["SQL 문자열 연결"]
sql_injection["SQL 인젝션"]
prepared_statement["플레이스홀더(프리페어드 스테이트먼트)"]
secret_logging["로그에 기밀 정보 출력"]
secret_log_exposure["로그를 통한 기밀 정보 노출"]
dpapi["DPAPI"]
plaintext_secret_storage["비밀 정보의 평문 저장"]
windows_service["Windows 서비스"]
admin_rights -->|"에서 구성할 수 있다"| execution_level_asinvoker
admin_rights -->|"에서 구성할 수 있다"| execution_level_requireadministrator
execution_level_requireadministrator -->|"사용은 비권장"| desktop_app
admin_privilege_separation -->|"권장되는 대응"| execution_level_requireadministrator
execution_level_requireadministrator -.->|"원인이 될 수 있다"| dll_search_order_hijacking
unvalidated_dll_loading -->|"원인이 될 수 있다"| dll_search_order_hijacking
code_signing_cert -->|"방지한다"| unsigned_binary
code_signing_timestamp -->|"완화한다"| certificate_expiry
update_integrity_verification -->|"방지한다"| unverified_update_application
code_signing_cert -->|"권장되는 대응"| update_integrity_verification
certificate_pinning -->|"권장되는 대응"| certificate_validation_bypass
certificate_pinning -.->|"전제로 한다"| certificate_revocation_check
certificate_validation_bypass -->|"사용은 비권장"| desktop_app
sql_string_concatenation -->|"원인이 될 수 있다"| sql_injection
prepared_statement -->|"방지한다"| sql_injection
secret_logging -->|"원인이 될 수 있다"| secret_log_exposure
dpapi -->|"권장되는 대응"| plaintext_secret_storage
app_secrets -.->|"전제로 한다"| dpapi
admin_privilege_separation -.->|"이용한다"| windows_service
code_signing_cert -.->|"전제로 한다"| code_signing_timestamp
prepared_statement -->|"권장되는 대응"| desktop_app
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 21건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 이 글의 대상과 「최소한」의 의미
2.1. 대상으로 하는 범위
이 글에서 가정하는 것은 다음과 같은 Windows 앱입니다.
- WPF / WinForms / WinUI 데스크톱 앱
- C++ / C# Win32 앱
- 사내 배포 도구, 장치 연동 도구, 감시 도구
- 보조 EXE, Windows 서비스, 업데이터를 포함하는 구성
- EXE / MSI / MSIX로 배포하는 업무용 소프트웨어
여기서 말하는 「최소한」은 감사를 통과하는 최종 형태가 아니라, 이것이 빠져 있으면 평범하게 사고로 이어지는 항목입니다.
flowchart TB
accTitle: 이 글에서 「최소한」의 의미
accDescr: 이 글의 최소한은 감사를 통과하는 최종 형태가 아니라, 빠져 있으면 평범하게 사고로 이어지는 항목을 가리킨다는 구분을 나타내는 그림.
au1["감사를 통과하는 최종 형태"] -.->|"여기서는 가리키지 않음"| mn1["이 글의 「최소한」"]
ac1["빠져 있으면 평범하게 사고로 이어지는 항목"] -->|"이것을 가리킴"| mn1
그림3: 「최소한」이 가리키는 것은 감사 대응의 최종 형태가 아니라, 빠지면 평범하게 사고로 이어지는 항목 쪽입니다.
코드 예의 전제도 맞춰 둡니다. C# 예는 .NET 8 이후, C++ 예는 Win32 API를 가정합니다. .NET Framework 4.8에서도 생각은 그대로이지만, 권장하는 작성 방식이 바뀐 곳이 있습니다. 대표 예가 3.6의 ServicePointManager 주변으로, 신규 코드에서는 IHttpClientFactory와 HttpClient를 쓰는 전제로 바뀌었습니다. 이전 세대 코드를 다시 읽을 때는 그 점만 주의해 주십시오.
2.2. 대상 외
한편, 이 글의 중심에서는 빼는 것도 있습니다.
- 기업 전체의 제로 트러스트 설계
- EDR / SIEM / DLP / MDM의 전체 운영
- 커널 드라이버의 상세한 하드닝
- 암호 설계 자체를 처음부터 하는 이야기
- 고도 위협 분석이나 포렌식 절차
즉, 「조직 전체의 거대한 보안 대책」이 아니라 Windows 앱 개발자가 릴리스 전에 스스로 빠뜨리기 어려운 기본 기준을 다룹니다.
flowchart TB
accTitle: 대상과 대상 외의 구분
accDescr: 조직 전체의 거대한 보안 대책은 대상 외로 두고, Windows 앱 개발자가 릴리스 전에 스스로 빠뜨리기 어려운 기본 기준을 대상으로 한다는 범위의 구분을 나타내는 그림.
org1["조직 전체의 거대한 대책"] -.->|"이 글에서는 다루지 않음"| sc1["이 글의 범위"]
dev1["개발자가 스스로 빠뜨리기 어려운 기본 기준"] -->|"이것을 다룸"| sc1
그림4: 다루는 것은 조직 전체의 대책이 아니라, 개발자가 릴리스 전에 스스로 지킬 수 있는 기본 기준입니다.
3. 먼저 보는 체크리스트
세세한 논의 전에, 우선 전체를 조망할 수 있는 표를 둡니다. 여기만 봐도 다시 볼 위치의 감은 잡힙니다.
3.1. 전체 모습
| 확인할 항목 | 최소한 할 일 | 전형적인 NG |
|---|---|---|
| 실행 권한 | asInvoker를 기본으로 하고, 권한 상승이 필요한 처리만 분리한다 |
앱 전체를 requireAdministrator로 한다 |
| 배포물의 신뢰성 | EXE / DLL / MSI / MSIX에 코드 서명하고, 타임스탬프도 붙인다 | 서명하지 않은 채로 배포한다 |
| 업데이트 | 업데이트 소스를 고정하고, HTTPS와 서명 확인으로 변조를 탐지한다 | HTTP로 받은 뒤 그대로 덮어쓴다 |
| 기밀 정보 | 소스 코드나 평문 설정에 비밀을 두지 않고, DPAPI / Credential Locker 등을 쓴다 | API 키나 연결 문자열을 설정 파일에 평문으로 둔다 |
| 통신 | HTTPS를 쓰고, 인증서 검증을 무효화하지 않는다 | return true로 인증서 검증을 항상 건너뛴다 |
| 외부 입력 | SQL, 파일, IPC, URI, CSV, JSON 등을 모두 검증한다 | 「사내 도구니까」라며 그대로 통과시킨다 |
| DLL 로드 | 절대 경로, SetDefaultDllDirectories, 안전한 검색 순서를 쓴다 |
LoadLibrary("foo.dll")를 현재 디렉터리에 맡긴다 |
| 로그 | 토큰, 비밀번호, PII를 마스킹하고, 사용자용 오류는 구분해 낸다 | 예외 상세나 연결 문자열을 그대로 표시·저장한다 |
| 의존 관계 | SDK, NuGet, VC++ 런타임, OSS 의존을 지속적으로 업데이트한다 | 수년 단위로 고정하고, 취약성 정보도 추적하지 않는다 |
3.2. 권한은 asInvoker를 기본으로 한다
Windows 앱에서 가장 먼저 다시 보고 싶은 곳이 여기입니다. 앱 전체를 관리자 권한으로 실행하면 버그, DLL 바꿔 치기, 설정 파일 오독, 외부 입력의 허점이 그대로 강한 권한으로 실행됩니다.
flowchart TB
accTitle: 앱 전체를 관리자 권한으로 둔 경우의 위험
accDescr: 앱 전체를 관리자 권한으로 실행하면 버그, DLL 바꿔 치기, 설정 파일 오독, 외부 입력의 허점이 그대로 강한 권한으로 실행된다는 것을 나타내는 그림.
all1["앱 전체를 관리자 권한으로 실행"] --> bug1["버그"]
all1 --> swp1["DLL 바꿔 치기"]
all1 --> inp1["설정 오독·입력 허점"]
bug1 --> pw1["그대로 강한 권한으로 실행된다"]
swp1 --> pw1
inp1 --> pw1
그림5: 앱 전체를 권한 상승시키면, 안고 있는 허점 전부가 강한 권한으로 실행됩니다.
기본 방침은 다음과 같습니다.
- 일반 UI 앱은
asInvoker - 관리자 권한이 필요한 처리만 별도 프로세스나 service로 분리한다
- 권한 상승은 필요한 순간에만 한다
- 보조 EXE나 service에 넘기는 입력도 검증한다
평소에는 열람과 편집만 하는 데스크톱 앱에서, 설치나 방화벽 설정 변경만 관리자 권한이 필요하다면, 앱 전체를 requireAdministrator로 두기보다 권한 상승이 필요한 부분만 broker로 모으는 편이 안전합니다.
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
「관리자로 돌리면 편하다」는 대개 나중에 영향을 줍니다. 최소 권한으로 실행하고, 그래도 필요한 조작만 잘라 내는 편이 사고 영향 범위는 꽤 작아집니다.
flowchart TB
accTitle: 권한 상승이 필요한 처리만 분리하는 구도
accDescr: 일반 UI 앱은 asInvoker로 실행하고, 관리자 권한이 필요한 처리만 별도 프로세스나 service의 broker로 모아, 권한 상승은 필요한 순간에만 하는 구도를 나타내는 그림.
ui1["UI 앱(asInvoker)"] -->|"필요한 순간에만 의뢰"| br1["broker(별도 EXE / service)"]
br1 --> el1["권한 상승이 필요한 처리만 실행"]
ui1 -.-> vd1["broker에 넘기는 입력도 검증한다"]
그림6: 평소에는 asInvoker로 실행하고, 권한 상승이 필요한 처리만 broker로 모으면 사고 영향 범위가 작아집니다.
3.3. 바이너리와 인스톨러에 서명한다
Windows에서는 배포물의 신뢰성이 결정적입니다. 사용자가 만지는 것은 소스 코드가 아니라 EXE, DLL, MSI, MSIX, 업데이터입니다. 여기가 서명되지 않았으면 운영상 설명도, 변조 탐지도, 배포 시의 안심도 약해집니다.
flowchart TB
accTitle: 사용자가 만지는 것은 배포물
accDescr: 사용자가 만지는 것은 소스 코드가 아니라 EXE, DLL, 인스톨러, 업데이터이며, 여기가 서명되지 않았으면 변조 탐지나 운영상 설명이 약해진다는 것을 나타내는 그림.
us1["사용자가 만지는 것"] --> bin1["EXE / DLL"]
us1 --> pkg1["MSI / MSIX"]
us1 --> upd1["업데이터"]
bin1 --> ns1["서명되지 않았으면 변조 탐지도 설명도 약하다"]
pkg1 --> ns1
upd1 --> ns1
그림7: 공격 표면이 되는 것은 배포물 자체이며, 서명하지 않은 채면 신뢰의 근거를 잃습니다.
최소한 봐 두고 싶은 것은 이 정도입니다.
- EXE / DLL / MSI / MSIX를 서명한다
- 인스톨러뿐 아니라 업데이트에 쓰는 보조 바이너리도 서명한다
- 타임스탬프를 붙인다
- 인증서 기한과 갱신 절차를 release 절차에 넣는다
특히 타임스탬프를 붙이지 않은 서명은 인증서 기한 만료 후 검증에서 곤란해지기 쉽습니다. 「서명했으니 끝」이 아니라 서명 + 타임스탬프까지를 release 절차에 넣어 두는 편이 안정적입니다.
flowchart TB
accTitle: 서명과 타임스탬프
accDescr: 타임스탬프를 붙이지 않은 서명은 인증서 기한 만료 후 검증에서 곤란해지기 쉽고, 서명과 타임스탬프까지를 release 절차에 넣으면 안정된다는 것을 나타내는 그림.
sg2["서명만"] --> tr1["인증서 기한 만료 후 검증에서 곤란해지기 쉽다"]
ts1["서명 + 타임스탬프"] --> st2["기한 만료 후 검증에서 곤란해지기 어렵다"]
ts1 -.-> fl1["release 절차에 넣어 둔다"]
그림8: 서명만으로 끝내지 않고, 타임스탬프까지 포함해 release 절차에 넣어 둡니다.
MSIX를 쓴다면 패키지 서명은 전제입니다. MSI / EXE 배포에서도 적어도 인스톨러 본체와 주요 실행 바이너리는 서명해 두는 편이 좋습니다.
3.4. 업데이트 경로를 고정하고 변조 탐지를 넣는다
요즘 Windows 앱에서는 최초 설치보다 업데이트 경로가 더 오래 쓰입니다. 여기가 느슨하면 본체를 정성껏 만들어도 업데이터가 가장 약한 곳이 됩니다.
flowchart TB
accTitle: 업데이트 경로가 가장 오래 쓰인다
accDescr: 최초 설치보다 업데이트 경로가 더 오래 쓰이므로, 업데이트 주변이 느슨하면 업데이터가 앱 전체에서 가장 약한 곳이 된다는 것을 나타내는 그림.
ins1["최초 설치"] -.->|"쓰이는 것은 한 번"| ap1["앱의 수명"]
up2["업데이트 경로"] -->|"오래 계속 쓰인다"| ap1
up2 --> wk2["느슨하면 가장 약한 곳이 된다"]
그림9: 업데이트 경로는 최초 설치보다 오래 쓰이며, 느슨하게 만들면 거기가 최대 약점이 됩니다.
업데이트 주변에서 최소한 생각해 두고 싶은 것은 이 다섯 가지입니다.
- 업데이트 파일 취득은 HTTPS 전제
- 다운로드한 업데이트 패키지의 서명이나 해시를 검증한다
- 업데이트 소스 URL을 코드나 설정으로 무제한 바꿀 수 없게 한다
- 업데이트 모듈 자체도 서명한다
- 롤백이나 실패 시 복구 절차를 정한다
MSIX + App Installer를 택할 수 있다면 업데이트 구조를 OS 쪽으로 모으기 쉽습니다. 반면 자체 업데이터를 가진다면 통신의 안전성과 배포물의 진위 양쪽을 확인해야 합니다. HTTPS만으로는 「통신 경로」는 지켜도 「그 파일이 정말 자신이 발행한 것인지」까지는 보장하지 않습니다.
flowchart TB
accTitle: 업데이트에서 확인할 두 가지
accDescr: 자체 업데이터에서는 HTTPS에 의한 통신의 안전성과, 다운로드한 업데이트 패키지의 서명이나 해시에 의한 진위 양쪽을 확인해야 한다는 것을 나타내는 그림.
dl1["업데이트 파일을 HTTPS로 취득"] --> vf1["서명이나 해시를 검증"]
vf1 --> ap2["검증을 통과한 것만 적용"]
dl1 -.-> lim1["HTTPS가 지키는 것은 통신 경로뿐"]
vf1 -.-> own1["자신의 발행물인지 확인한다"]
그림10: HTTPS로 받아도 진위는 별개이며, 서명이나 해시 검증을 통과한 뒤에 적용합니다.
3.5. 비밀 정보를 소스 코드나 평문 설정에 두지 않는다
여기는 실무에서 정말 사고로 이어지기 쉬운 곳입니다. 「사내 도구니까」「어차피 exe만 배포하니까」라며 연결 문자열, API 키, 공유 폴더 자격 정보, 고정 토큰을 소스 코드나 설정 파일에 두기 쉽습니다.
최소한 이런 두는 방식은 피하고 싶습니다.
- 소스 코드에 직접 쓴 API 키
appsettings.json이나app.config의 평문 비밀번호- 리포지토리에 들어간 연결 문자열
- 복호화 키와 암호문을 같은 곳에 두는 설계
- 사용자별이 아니라 전원 공통의 고정 자격 정보
Windows 앱에서 현실적으로 택할 수 있는 선택은 대략 이 네 가지입니다.
- Windows의 자격 정보를 저장하고 싶다 packaged desktop app / WinUI 계열이면 Credential Locker를 검토한다
- 로컬에 비밀을 암호화해 저장하고 싶다
Win32 / .NET이면 DPAPI /
ProtectedData를 쓴다 - 연결 대상이 Windows 인증이나 통합 인증을 쓸 수 있다 가능하면 앱에 비밀번호를 가지게 하지 않는다
- 클라우드나 서버 쪽에서 비밀을 관리할 수 있다 클라이언트에 장기 비밀을 넣지 않는 설계를 우선한다
C#이라면 적어도 다음과 같이 DPAPI를 쓰기만 해도 평문 저장보다는 꽤 낫습니다. 저장과 읽기의 왕복까지 쓰면 이렇게 됩니다.
// C# / .NET 8. ProtectedData는 Windows 전용이며,
// .NET에서는 NuGet 패키지 System.Security.Cryptography.ProtectedData가 필요합니다.
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
public static class SecretStore
{
// 복호화 때에도 같은 값이 필요합니다. null이어도 동작하지만, 붙이는 편이 안전합니다.
private static readonly byte[] Entropy = [0x4b, 0x53, 0x2d, 0x76, 0x31];
public static void Save(string path, string secretText)
{
byte[] plaintext = Encoding.UTF8.GetBytes(secretText);
byte[] ciphertext = ProtectedData.Protect(
plaintext,
Entropy,
DataProtectionScope.CurrentUser);
// 바이트 열 그대로 파일에 두어도 되지만, 설정 파일에 넣을 거면 Base64로 합니다.
File.WriteAllText(path, Convert.ToBase64String(ciphertext));
CryptographicOperations.ZeroMemory(plaintext);
}
public static string Load(string path)
{
byte[] ciphertext = Convert.FromBase64String(File.ReadAllText(path));
// 저장할 때와 같은 사용자, 같은 entropy가 아니면 CryptographicException이 됩니다.
byte[] plaintext = ProtectedData.Unprotect(
ciphertext,
Entropy,
DataProtectionScope.CurrentUser);
try
{
return Encoding.UTF8.GetString(plaintext);
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
}
호출 쪽은 이렇게 됩니다.
string path = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SampleApp",
"token.dat");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
SecretStore.Save(path, "example-api-key");
string restored = SecretStore.Load(path);
여기서 중요한 것은 「암호화했으니 안전하다」가 아니라 누가 복호화할 수 있는지를 설계로 정하는 것입니다.
CurrentUser로 할지 LocalMachine으로 할지에 따라 의미가 꽤 달라집니다. CurrentUser는 저장한 사용자만 복호화할 수 있고, LocalMachine은 같은 머신의 누구든 복호화할 수 있습니다. 서비스로 다른 계정에서 실행하는 경우나 사용자를 바꾸는 운영이 있는 경우에는, 여기를 먼저 정해 두지 않으면 나중에 「읽을 수 없다」「읽어 버린다」 중 한쪽으로 막힙니다.
flowchart TB
accTitle: DPAPI에서 누가 복호화할 수 있는가
accDescr: DPAPI 스코프를 CurrentUser로 하면 저장한 사용자만 복호화할 수 있고, LocalMachine으로 하면 같은 머신의 누구든 복호화할 수 있으므로, 누가 복호화할 수 있는지를 설계에서 먼저 정해야 한다는 것을 나타내는 그림.
dc1["누가 복호화할 수 있는지를 설계로 정한다"] -->|"CurrentUser"| cu1["저장 사용자만 복호화 가능"]
dc1 -->|"LocalMachine"| lm1["동일 머신의 누구든 복호화 가능"]
dc1 -.-> lt1["정하지 않으면 나중에 막힌다"]
그림11: DPAPI는 스코프 선택에 따라 복호화할 수 있는 범위가 달라지므로, 먼저 「누가 복호화할 수 있는지」를 정합니다.
참고로 DPAPI는 사용자 프로필에 키를 두므로, 프로필이 로드되지 않은 상태(impersonation 중 등)에서는 복호화에 실패한다고 문서에 명시되어 있습니다. 서비스에서의 이용을 생각한다면 이 점도 확인해 두십시오.
flowchart TB
accTitle: DPAPI의 키와 사용자 프로필
accDescr: DPAPI는 사용자 프로필에 키를 두므로, impersonation 중처럼 프로필이 로드되지 않은 상태에서는 복호화에 실패한다는 것을 나타내는 그림.
ky1["DPAPI의 키"] --> pf1["사용자 프로필에 있다"]
pf1 -->|"프로필 미로드(impersonation 중 등)"| fe1["복호화에 실패한다"]
fe1 -.-> sv2["서비스에서의 이용에서는 확인 필요"]
그림12: DPAPI의 키는 사용자 프로필에 있으며, 프로필이 로드되지 않은 상태에서는 복호화할 수 없습니다.
SQL Server 연결이라면 온프레미스 환경에서는 Windows 인증을 첫 후보로 둘 수 있는 경우가 있습니다.
어쩔 수 없이 연결 문자열에 자격 정보를 넣을 거면, 적어도 Persist Security Info=False를 유지하고, 평문 설정 파일에 그대로 두지 않는 편이 안전합니다.
3.6. 통신은 HTTPS 전제, 인증서 검증을 끄지 않는다
개발 중에만 넣었다는 우회가 그대로 프로덕션에 남는다. 통신 주변의 사고는 대개 이 패턴입니다.
특히 출시 빌드에 남기 쉬운 것이 이런 코드나 설정입니다.
ServicePointManager.ServerCertificateValidationCallback += ... => trueHttpClientHandler.DangerousAcceptAnyServerCertificateValidator- 인증서 해지 확인을 무효화한 채로 출시
- 개발용 자체 서명 인증서 전제의 코드를 프로덕션에 남긴다
최소한의 방침은 단순합니다.
- 프로덕션 통신은 HTTPS
- 인증서 검증을 항상 건너뛰지 않는다
- 예외적으로 검증을 완화해야 한다면 대상 호스트와 인증서를 한정한다
- 개발용 회피 코드는 빌드 조건이나 설정으로 확실히 제거한다
- .NET이라면 해지 확인도 의식한다
나쁜 예는 대개 이렇습니다.
ServicePointManager.ServerCertificateValidationCallback +=
(_, _, _, _) => true;
보기에는 편하지만, 이것은 「이 HTTPS 통신은 누구에게 연결해도 통과시킨다」에 가까운 동작이 됩니다. 인증서 검증을 빼면 HTTPS를 쓰더라도 내용은 꽤 뼈대가 빠집니다.
어느 .NET을 전제로 하는지에 따라 작성 방식이 달라진다
ServicePointManager에 전역 설정을 두는 것은 .NET Framework 시대의 작성 방식입니다. 신규 코드에서는 IHttpClientFactory에서 HttpClient를 받고, TLS 주변 설정이 필요하면 SocketsHttpHandler나 HttpClientHandler 쪽에 두는 편이 자연스럽습니다.
다만 「오래된 API이니 이제 적용되지 않을 것」이라고 방치하는 것은 위험합니다. Microsoft 문서에는 ServicePointManager.ServerCertificateValidationCallback이 .NET 9 이후에는 SocketsHttpHandler.SslOptions의 RemoteCertificateValidationCallback에 매핑된다고 적혀 있습니다. 즉, 어딘가 한 줄의 => true가 HttpClient 통신까지 통과시켜 버리는 일이 있습니다.
flowchart TB
accTitle: 오래된 콜백이 지금의 통신에 적용되는 경로
accDescr: ServicePointManager의 ServerCertificateValidationCallback은 .NET 9 이후 SocketsHttpHandler의 검증 콜백에 매핑되므로, 어딘가 한 줄의 true 반환이 HttpClient 통신까지 통과시켜 버릴 수 있다는 경로를 나타내는 그림.
old1["ServicePointManager의 검증 콜백"] -->|".NET 9 이후 매핑된다"| new1["SocketsHttpHandler 쪽 검증"]
new1 --> ef1["HttpClient 통신에도 적용된다"]
ef1 -.-> rk2["한 줄의 true 반환이 통신 전체의 뼈대를 뺄 수 있다"]
그림13: 오래된 API에 둔 검증 건너뛰기가 매핑을 통해 지금의 HttpClient 통신까지 통과합니다.
예외적으로 검증을 느슨하게 하고 싶을 때는 프로세스 전체에 적용되는 전역 설정이 아니라 그 핸들러에만 닫힌 형태로 합니다.
// C# / .NET 8. 특정 호스트와 인증서만 예외로 다루는 예입니다.
// 개발용으로 느슨하게 하더라도, 대상을 한정하지 않으면 전역으로 끄는 것과 다르지 않습니다.
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
// 대상 인증서의 지문. 설정에서 읽어도 됩니다.
const string ExpectedThumbprint = "2B0C4E6A8D1F3B5D7F9A1C3E5A7C9E1B3D5F7A91";
var handler = new HttpClientHandler
{
CheckCertificateRevocationList = true, // 해지를 확인하러 갑니다. 기본값은 false
ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) =>
{
if (errors == SslPolicyErrors.None)
{
return true;
}
// 인정하는 것은 「이 단말이 사내 CA를 신뢰하지 않는다」는 한 점뿐입니다.
// 인증서가 오지 않음·호스트 이름이 일치하지 않음은 인정하지 않습니다
if (errors != SslPolicyErrors.RemoteCertificateChainErrors)
{
return false;
}
// 상대와 인증서를 고정합니다
if (request.RequestUri?.Host != "device.internal.example"
|| certificate is null
|| !string.Equals(certificate.Thumbprint, ExpectedThumbprint,
StringComparison.OrdinalIgnoreCase))
{
return false;
}
// 핀닝한 인증서 하나라도 기한 만료와 해지는 인정하지 않습니다.
// 인정하는 순간, 「키가 새어 해지한 인증서」를 계속 쓰는 경로가 됩니다
return chain is not null
&& chain.ChainStatus.All(s =>
s.Status is X509ChainStatusFlags.NoError
or X509ChainStatusFlags.UntrustedRoot
or X509ChainStatusFlags.PartialChain);
},
};
using var client = new HttpClient(handler);
ExpectedThumbprint는 대상 인증서의 지문을 상수나 설정에서 줍니다.
여기서 중요한 것은 errors와 chain.ChainStatus를 「못 본 척하지 않는」 것입니다. 지문이 일치했다고 true를 반환해 버리면, 그 인증서가 기한 만료여도, 키가 새어 해지한 뒤여도, 계속 통과합니다. 핀닝은 「이 인증서만 믿는다」는 뜻이지, 「이 인증서면 무슨 일이 있어도 믿는다」가 아닙니다. 위 코드에서 허용하는 것은 UntrustedRoot와 PartialChain(=사내 CA가 이 단말에 들어가 있지 않음)뿐이며, NotTimeValid(기한 만료)나 Revoked(해지)는 그대로 거부가 됩니다.
해지를 보려면 CheckCertificateRevocationList = true가 필요합니다(기본값은 false이며, 해지는 확인되지 않습니다). 반대로, 사내 CA가 CRL도 OCSP도 공개하지 않으면 RevocationStatusUnknown으로 걸러집니다. 그것이 올바른 동작입니다. 해지 확인 수단을 마련할 수 없다면 인증서 유효 기간을 짧게 하거나, 핀닝한 값을 다시 배포할 수 있는 경로를 먼저 만들어 두거나, 둘 중 하나로 메우십시오. 「해지할 수 없는 채로 장기 인증서를 핀닝하는」 것이 가장 위험한 상태입니다.
flowchart TB
accTitle: 핀닝 시 판정 흐름
accDescr: 오류 없으면 통과하고, 체인 오류 이외는 거부하며, 호스트 이름과 지문을 고정하고, 체인 상태에서도 기한 만료나 해지는 거부한다는 예외 검증의 판정 흐름을 나타내는 그림.
e0["errors를 확인"] -->|"None"| pass1["통과"]
e0 -->|"체인 오류 이외"| rj1["거부"]
e0 -->|"체인 오류만"| hchk["호스트 이름과 지문을 대조"]
hchk -->|"불일치"| rj1
hchk -->|"일치"| cchk["체인 상태를 확인"]
cchk -->|"기한 만료·해지 등"| rj1
cchk -->|"사내 CA 미도입 상태만"| pass1
그림14: 핀닝해도 못 본 척하지 않는 판정 흐름이며, 기한 만료와 해지는 그대로 거부가 됩니다.
3.7. 외부 입력을 전부 「신뢰하지 않는 입력」으로 다룬다
Windows 앱은 Web 앱이 아니라서 입력 validation이 느슨해지기 쉽습니다. 하지만 실제로는 외부 입력의 입구가 생각보다 많습니다.
- 파일 경로
- CSV / Excel / JSON / XML
- 명령줄 인수
- named pipe / socket / COM / RPC / gRPC
- DB에 넘기는 문자열
- 레지스트리 값
- 클립보드
- URL / deep link
- 외부 장치나 SDK에서 돌아오는 데이터
특히 최소한 빠뜨리고 싶지 않은 것은 다음 세 가지입니다.
- SQL은 반드시 매개변수화한다 문자열 연결로 SQL을 만들지 않는다.
- 파일 경로는 정규화한 뒤에 쓴다 사용자가 지정한 경로를 그대로 삭제, 덮어쓰기, 전개에 쓰지 않는다.
- 외부 파일 읽기에는 크기 상한과 형식 검사를 넣는다 「열렸으니 안전하다」가 아니다.
SQL 예로 말하면, 이것은 피하고 싶습니다.
var sql = "SELECT * FROM Users WHERE Name = '" + userName + "'";
최소한도 이렇게 모으고 싶습니다.
using System.Data;
using Microsoft.Data.SqlClient;
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT * FROM Users WHERE Name = @name";
cmd.Parameters.Add("@name", SqlDbType.NVarChar, 256).Value = userName;
「사내 도구니까 입력은 신뢰할 수 있다」는 꽤 위험한 전제입니다. 현실에는 깨진 CSV, 예상 밖 파일 이름, 오래된 DB 데이터, 운영자의 수기 입력 실수, 다른 도구가 쓴 어중간한 JSON이 평범하게 들어옵니다.
flowchart TB
accTitle: 사내 도구에도 깨진 입력은 온다
accDescr: 사내 도구여도 깨진 CSV나 예상 밖 파일 이름, 오래된 DB 데이터, 수기 입력 실수, 어중간한 JSON이 평범하게 들어오므로, 외부 입력은 모두 신뢰하지 않는 입력으로 다룬다는 것을 나타내는 그림.
csv1["깨진 CSV"] --> in2["앱으로의 입력"]
fn1["예상 밖 파일 이름"] --> in2
hm1["수기 입력 실수·오래된 데이터"] --> in2
in2 --> tr2["모두 신뢰하지 않는 입력으로 검증"]
그림15: 사내 도구여도 깨진 입력은 평범하게 오므로, 입구마다 검증한 뒤에 씁니다.
3.8. DLL의 로드 위치를 모호하게 두지 않는다
이것은 Windows 특유의 함정입니다.
LoadLibrary("foo.dll")처럼 이름만으로 DLL을 읽히면, 검색 순서에 따라 의도하지 않은 위치의 DLL을 집어올 수 있습니다.
할 일은 정해져 있습니다.
- 가능하면 DLL의 절대 경로를 지정한다
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)를 이른 단계에서 설정한다AddDllDirectory로 검색 대상을 명시적으로 더한다SearchPath결과를 그대로LoadLibrary에 넘기는 설계를 피한다- safe DLL search mode에만 기대지 않는다
예를 들어 native code라면 프로세스 초기화의 이른 단계에서 다음을 넣는 설계는 유력합니다.
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
그리고 필요한 추가 디렉터리만 AddDllDirectory로 등록합니다.
여기는 「평소에는 동작한다」고 방치되기 쉽지만, 배포처에서 작업 디렉터리가 바뀌거나 다른 제품의 DLL이 PATH에 들어가 있으면 조용히 깨집니다. 보안뿐 아니라 장애 예방으로도 꽤 효과가 있습니다.
flowchart TB
accTitle: DLL 로드 위치의 고정
accDescr: 이름만으로 DLL을 읽히면 검색 순서에 따라 의도하지 않은 위치의 DLL을 집어올 수 있으며, 이른 단계에서 SetDefaultDllDirectories를 설정하고 AddDllDirectory로 검색 대상을 명시하며, 가능하면 절대 경로를 지정한다는 대책을 나타내는 그림.
nm1["이름만으로 로드"] --> pick2["검색 순서에 따라 의도하지 않은 DLL을 집어온다"]
fix1["조기에 SetDefaultDllDirectories"] --> add2["AddDllDirectory로 명시적으로 추가"]
add2 --> abs1["가능하면 절대 경로 지정"]
abs1 --> safe1["로드 위치가 모호해지지 않는다"]
그림16: 이름에 맡기는 로드를 그만두고, 검색 대상을 명시해 로드 위치를 고정합니다.
3.9. 로그와 예외에 기밀을 내지 않는다
장애 조사를 위해 로그를 늘리는 것은 중요합니다. 다만 로그는 기밀의 무덤이 되기 쉽습니다.
로그 주변에서 최소한 다시 보고 싶은 것은 다음과 같습니다.
- 비밀번호, Bearer token, API 키를 로그에 내지 않는다
- 연결 문자열을 통째로 내지 않는다
- 개인 정보나 업무 데이터 본문은 마스킹한다
- 예외 상세는 사용자용 화면과 내부 로그로 나눈다
- debug용 PII 로그를 프로덕션에서 켜지 않는다
- dump나 trace의 저장 위치 권한을 다시 본다
최근 .NET에서는 redaction을 전제로 한 정리도 하기 쉬워졌습니다. 적어도 「무엇이든 문자열화해 그대로 log」는 그만두고 싶습니다.
흔히 있는 실패를 몇 가지 듭니다.
- HTTP request / response body를 통째로 저장한다
- 인증 실패 시 토큰이나 헤더 전체를 출력한다
- 예외 메시지를 그대로 MessageBox에 낸다
- 유지보수용 ZIP에 기밀 로그를 전부 동봉한다
오류 표시는 예를 들어 다음과 같이 나눕니다.
- 사용자용: 「서버 연결에 실패했습니다. 네트워크 설정과 URL을 확인해 주십시오.」
- 내부 로그: 실패 대상 호스트, TLS 오류 종류, 상관 ID, stack trace, 재시도 횟수
이 분리만으로도 정보 유출과 조사성의 균형이 꽤 좋아집니다.
flowchart TB
accTitle: 오류 표시의 구분
accDescr: 오류가 났을 때 사용자에게는 간결한 안내만 내고, 내부 로그에는 실패 대상 호스트나 오류 종류, 상관 ID, stack trace 등 조사 정보를 남긴다는 구분을 나타내는 그림.
er1["오류 발생"] --> usr1["사용자용: 간결한 안내만"]
er1 --> lg2["내부 로그: 호스트·종류·상관 ID 등"]
lg2 -.-> bl1["유출을 막으면서 조사성을 유지한다"]
그림17: 사용자용 표시와 내부 로그를 나누는 것만으로, 유출과 조사성의 균형이 좋아집니다.
3.10. 의존 라이브러리와 개발 도구를 방치하지 않는다
마지막은 수수하지만 효과가 큰 항목입니다. 앱 본체를 정성껏 만들어도, 오래된 런타임이나 알려진 취약성이 있는 의존 라이브러리를 그대로 쌓아 두면 기반이 무너집니다.
볼 항목 자체는 많지 않습니다.
- .NET SDK / runtime을 지원 범위 안의 판으로 유지한다
- NuGet / OSS 의존의 업데이트를 정기적으로 확인한다
- C++라면 런타임 재배포물이나 외부 DLL의 판 관리를 한다
- 취약성 정보 확인을 release 전 체크에 넣는다
- 의존 업데이트로 깨지지 않도록 smoke test를 준비한다
여기는 「나중에 한꺼번에 한다」가 가장 위험합니다. 반년, 1년 방치하면 업데이트 차이가 너무 커져, 보안 대응 자체가 무거운 작업이 됩니다.
flowchart TB
accTitle: 의존 업데이트를 방치한 경우
accDescr: 의존 관계의 업데이트를 반년에서 1년 방치하면 업데이트 차이가 너무 커져, 보안 대응 자체가 무거운 작업이 된다는 흐름을 나타내는 그림.
pt1["나중에 한꺼번에 한다"] --> ac2["반년~1년의 방치"]
ac2 --> df1["업데이트 차이가 너무 커진다"]
df1 --> hw1["대응 자체가 무거운 작업이 된다"]
rg1["정기적으로 확인한다"] -.->|"이것을 피할 수 있다"| hw1
그림18: 의존 업데이트는 방치할수록 차이가 커지고, 대응 자체가 무거운 작업이 되어 갑니다.
3.11. 각 항목을 어떻게 확인하는가
체크리스트는 확인 방법과 세트로 있어야 비로소 기능합니다. 3.2부터 3.10의 항목에 대해, 릴리스 전에 실제로 돌려 볼 수 있는 것을 나열해 둡니다.
| 확인하고 싶은 것 | 확인 방법 |
|---|---|
| 권한 상승을 요구하지 않는가 | 앱 매니페스트에서 requestedExecutionLevel 값을 본다. 소스라면 app.manifest, 배포물이라면 Sysinternals의 Sigcheck나 리소스 에디터로 확인한다 |
| 서명과 타임스탬프 | PowerShell에서 Get-AuthenticodeSignature .\app.exe를 실행하고, Status가 Valid인지, TimeStamperCertificate가 들어 있는지를 본다. 동봉 DLL과 updater도 하나씩 본다 |
| 인증서 검증을 끄지 않았는가 | 소스 전체를 ServerCertificateValidationCallback, DangerousAcceptAnyServerCertificateValidator, ServerCertificateCustomValidationCallback, CheckCertificateRevocationList로 검색한다 |
| 비밀 정보의 직접 기입 | Password=, ApiKey, Secret, Token, ConnectionString으로 검색한다. 현재 소스뿐 아니라 리포지토리 이력도 대상으로 한다 |
| SQL의 조립 | "SELECT, "INSERT, + 를 포함하는 문자열 연결을 검색하고, Parameters.Add를 거쳤는지 확인한다 |
| DLL의 로드 위치 | Process Monitor에서 대상 프로세스로 좁히고, Path ends with .dll과 Result is NAME NOT FOUND 필터를 건다. 어디를 어떤 순서로 찾으러 갔는지가 보이므로, 의도하지 않은 폴더를 보지 않았는지 확인한다 |
| 의존 관계의 알려진 취약성 | dotnet list package --vulnerable --include-transitive를 실행한다 |
| 로그에 기밀이 나가지 않았는가 | 한 번 실행한 뒤, 출력된 로그를 Bearer , Password, Authorization으로 검색한다 |
코드 검색은 rg(ripgrep)여도 Visual Studio 검색이어도 상관없습니다. 한꺼번에 돌린다면 이 형태입니다.
Get-ChildItem -Recurse -Include *.cs,*.vb,*.cpp,*.h,*.config,*.json |
Select-String -Pattern 'ServerCertificateValidationCallback|DangerousAcceptAnyServerCertificateValidator|Password=|ApiKey' |
Select-Object Path, LineNumber, Line
중요한 것은 확인한 것 자체를 기록에 남기는 것입니다. 「검색했다」「0건이었다」까지 남겨 두면, 다음 릴리스에서는 차이만 보면 됩니다.
flowchart TB
accTitle: 확인을 기록에 남기는 효과
accDescr: 릴리스 전 확인은 결과가 0건이어도 기록에 남겨 두면, 다음 릴리스에서는 차이만 보면 되게 된다는 흐름을 나타내는 그림.
chk2["릴리스 전에 확인을 실행"] --> rec1["검색했다·0건이었다까지 기록"]
rec1 --> nx1["다음 릴리스는 차이만 보면 된다"]
그림19: 「확인했다」는 사실까지 기록해 두면, 다음부터는 차이 확인만으로 끝납니다.
4. 릴리스 전 체크리스트
리뷰나 출시 판정의 템플릿으로 그대로 쓸 수 있는 형태로 합니다. 표로 확인하기 쉽도록, 릴리스 전에 최소한 보고 싶은 항목을 카테고리별로 나열합니다.
4.1. 권한, 실행 방식
| 체크 항목 | 확인 | 메모 |
|---|---|---|
일반 실행은 asInvoker로 동작한다 |
□ | |
| 관리자 권한이 필요한 처리는 별도 EXE / service 등으로 분리했다 | □ | |
| service를 쓰는 경우, 필요 이상으로 강한 실행 계정으로 두지 않았다 | □ | |
%ProgramFiles% 아래와 사용자 데이터 아래의 책임을 나눴다 |
□ |
4.2. 배포, 서명
| 체크 항목 | 확인 | 메모 |
|---|---|---|
| EXE / DLL / MSI / MSIX / updater에 서명했다 | □ | |
| 서명에 타임스탬프를 붙였다 | □ | |
| 인증서 기한과 갱신 절차를 release 흐름에 넣었다 | □ | |
| 배포물의 해시 확인이나 변조 탐지 방법이 정해져 있다 | □ |
4.3. 업데이트
| 체크 항목 | 확인 | 메모 |
|---|---|---|
| 업데이트 취득은 HTTPS로 한다 | □ | |
| 다운로드 후 서명 또는 해시를 검증한다 | □ | |
| 업데이트 소스 URL을 마음대로 바꾸기 어려운 설계다 | □ | |
| 업데이트 실패 시 롤백 또는 재시도 방침이 있다 | □ |
4.4. 비밀 정보
| 체크 항목 | 확인 | 메모 |
|---|---|---|
| 비밀번호, API 키, 연결 문자열을 소스 코드에 직접 쓰지 않았다 | □ | |
| 평문 설정 파일에 비밀을 두지 않았다 | □ | |
| 로컬 저장이 필요한 비밀은 DPAPI / Credential Locker 등으로 보호한다 | □ | |
| 가능한 곳은 Windows 인증이나 사용자 자격 정보로 모았다 | □ |
4.5. 통신
| 체크 항목 | 확인 | 메모 |
|---|---|---|
| 프로덕션 통신은 HTTPS를 쓴다 | □ | |
DangerousAcceptAnyServerCertificateValidator나 => true를 출시 빌드에 남기지 않았다 |
□ | |
| 해지 확인이나 호스트 이름 검증을 의식한다 | □ | |
| 개발용 인증서 전제의 코드나 설정이 프로덕션에 섞이지 않았다 | □ |
4.6. 입력, 데이터 액세스
| 체크 항목 | 확인 | 메모 |
|---|---|---|
| SQL은 매개변수화했다 | □ | |
| 명령줄, 파일, IPC, URI 등의 입력에 상한과 형식 검사가 있다 | □ | |
| 경로 조작은 정규화해 루트 이탈을 막는다 | □ | |
| 예외 메시지를 그대로 화면에 내지 않았다 | □ |
4.7. DLL과 실행 환경
| 체크 항목 | 확인 | 메모 |
|---|---|---|
| DLL의 로드 위치를 명시했다 | □ | |
SetDefaultDllDirectories / AddDllDirectory 등으로 검색 순서를 제어한다 |
□ | |
| 현재 디렉터리나 PATH에 맡기는 DLL 로드를 하지 않았다 | □ | |
| 배포처에서 동적 로드에 필요한 파일들을 파악하고 있다 | □ |
4.8. 로그, 운영
| 체크 항목 | 확인 | 메모 |
|---|---|---|
| 토큰, 비밀번호, PII를 로그에 내지 않았다 | □ | |
| 내부 로그와 사용자용 메시지를 나눴다 | □ | |
| dump / trace / log의 저장 위치 권한을 다시 봤다 | □ | |
| SDK와 의존 라이브러리의 업데이트 상황을 확인했다 | □ |
5. 흔히 있는 NG
실무에서 자주 보는 것은 대개 이런 착각입니다.
5.1. 「사내 도구니까 괜찮다」
사내 도구여도 깨진 파일, 오조작, 반입 단말, 공유 폴더, 오래된 DLL, 느슨한 권한 설정은 평범하게 있습니다. 인터넷에 공개하지 않아도 공격 표면은 사라지지 않습니다.
5.2. 「HTTPS니까 안전하다」
HTTPS는 중요하지만, 인증서 검증을 무효화하면 의미가 꽤 옅어집니다. 또한 업데이트 배포에서는 HTTPS뿐 아니라 배포물의 진위 확인도 필요합니다.
5.3. 「암호화했으니 안전하다」
복호화 키의 위치, 복호화 권한, 사용자 경계, 머신 경계가 정리되어 있지 않으면 암호화만으로는 부족합니다.
특히 LocalMachine으로 보호한 값을 「사용자별 비밀」이라고 생각하고 쓰면 나중에 혼란스럽습니다.
5.4. 「로그를 늘리면 조사할 수 있다」
로그만 많고 토큰이나 개인 정보가 흘러나오면, 그것 자체가 인시던트가 됩니다. 조사성이 필요하다면 무엇을 남기고 무엇을 가릴지를 정하는 편이 먼저입니다.
5.5. 「관리자로 돌리면 해결」
처음에는 편하지만, 나중에 UAC, 배포, 지원, 권한 경계, DLL 로드, 파일 저장 위치에서 대개 힘들어집니다. 최소 권한이 장기적으로는 안정적입니다.
flowchart TB
accTitle: 「관리자로 돌리면 해결」의 결말
accDescr: 관리자 권한으로의 상시 사용은 처음에는 편해도, 나중에 UAC나 배포, 지원, 권한 경계, DLL 로드, 파일 저장 위치에서 힘들어지며, 최소 권한이 장기적으로는 안정된다는 것을 나타내는 그림.
ez1["관리자로 돌리면 해결"] --> ez2["처음에는 편하다"]
ez2 --> pain1["UAC·배포·지원에서 힘들어진다"]
ez2 --> pain2["권한 경계·저장 위치에서 힘들어진다"]
lp1["최소 권한으로 실행"] -->|"장기적으로는 안정"| ok2["힘듦을 피할 수 있다"]
그림20: 관리자 상시 사용의 편함은 처음뿐이며, 장기적으로는 최소 권한이 안정적입니다.
6. 대략적인 우선순위
전부를 한 번에 하기가 무겁다면, 우선순위는 대개 이렇게 됩니다.
나열하는 기준은 사고 났을 때의 피해 크기와 고치는 비용의 낮음의 곱입니다. 3장은 「권한 → 배포 → 구현 → 운영」이라는 설계의 흐름으로 나열했지만, 이쪽은 「위험한 것부터」의 순서이므로 장의 순서와는 일치하지 않습니다. 대응하는 절을 붙여 둡니다.
- 관리자 권한의 재검토(3.2)
먼저
requireAdministrator의 상시 사용을 그만둔다. 피해 범위가 한 단계 바뀌는 데 비해, 설계 변경으로는 작게 끝나는 경우가 많다. - 서명과 타임스탬프(3.3) 배포물의 신뢰성을 갖춘다. 절차에 넣는 것만으로 끝나고, 나중에 넣으면 재배포가 필요하다.
- 비밀 정보 빼내기(3.5) 소스 코드, 평문 설정에서 비밀을 뺀다. 새었을 때의 피해가 크고, 샌 뒤에는 되돌릴 수 없다.
- HTTPS + 인증서 검증 바로잡기(3.6)
=> true계열을 출시 빌드에서 지운다. 삭제하는 것만으로 고쳐지는 경우가 많고, 방치하면 통신을 통째로 믿을 수 없다. - SQL / 파일 / IPC 입력의 재검토(3.7) 문자열 연결이나 무검증 입력을 줄인다. 건수는 많지만, 한 곳씩 고칠 수 있다.
- DLL 로드의 고정(3.8) 이름만 로드, PATH에 맡기기를 그만둔다. 기동 처리의 일부를 고치는 작업이며, 장애 예방에도 된다.
- 로그의 마스킹(3.9) 사고 시에 로그가 2차 재해가 되지 않게 한다. 출력 곳이 많은 만큼 시간은 걸린다.
- 의존 업데이트의 정례화(3.10) 릴리스마다 확인하는 흐름으로 만든다. 한 번에 끝나지 않으므로, 구조로 만들기까지가 일이다.
업데이트 경로(3.4)가 이 나열에 들어 있지 않은 것은, 자동 업데이트가 없는 앱도 있기 때문입니다. 있다면 2번의 서명과 같은 우선순위로 보십시오. 업데이트 모듈은 본체보다 약한 데 본체를 덮어쓸 수 있는 입장에 있습니다.
flowchart TB
accTitle: 우선순위를 매기는 방법
accDescr: 사고 났을 때의 피해 크기와 고치는 비용의 낮음의 곱으로 위험한 것부터 나열하고, 자동 업데이트가 있는 앱에서는 업데이트 경로를 서명과 같은 우선순위로 본다는 생각을 나타내는 그림.
dmg1["피해의 크기"] --> mul1["곱으로 순위를 정한다"]
cst1["고치는 비용의 낮음"] --> mul1
mul1 --> ord1["위험한 것부터 막는다"]
ord1 -.-> upn1["자동 업데이트가 있으면 업데이트 경로는 서명과 같은 우선순위"]
그림21: 우선순위는 피해 크기와 고치는 비용의 곱으로 정하고, 위험한 구멍부터 막습니다.
이 순서라면 「우선 명백히 위험한 구멍을 막는다」는 의미에서 진행하기 쉽습니다.
7. 정리
Windows 앱 개발의 보안은, 특별한 제품이나 거대한 구조를 넣기 전에 권한, 서명, 비밀 정보, 통신, 입력, DLL, 로그 일곱 가지를 갖추는 것만으로도 꽤 달라집니다.
최소한의 기준을 한 마디씩으로 하면 이렇게 됩니다.
- 앱 전체를 관리자 권한으로 실행하지 않는다
- 배포물과 업데이트 패키지에 서명하고, 타임스탬프를 붙인다
- 비밀 정보를 소스 코드나 평문 설정에 두지 않는다
- HTTPS를 쓰더라도 인증서 검증을 끄지 않는다
- SQL, 파일, IPC 등의 외부 입력을 신뢰하지 않는다
- DLL의 로드 위치를 모호하게 두지 않는다
- 로그에 기밀을 내지 않는다
- 의존 라이브러리를 방치하지 않는다
보안 이야기는 넓지만, 처음부터 전부를 할 필요는 없습니다. 다만 위험한 기본 동작을 그대로 출시하지 않는다는 최소한만은, 꽤 이른 단계에서 갖출 가치가 있습니다.
8. 참고 자료
- Administrator Broker Model - Win32 apps
- How User Account Control works
- Authenticode Digital Signatures
- Time Stamping Authenticode Signatures
- Sign a Windows app package
- Credential Locker for Windows apps
- CryptProtectData function (dpapi.h)
- CA5359: Do not disable certificate validation
- CA5399: Enable HttpClient certificate revocation list check
- Configuring parameters - ADO.NET Provider for SQL Server
- Connection String Syntax - ADO.NET
- Dynamic-Link Library Security - Win32 apps
- SetDefaultDllDirectories function (libloaderapi.h)
- Data redaction in .NET
- ProtectedData Class - Windows 전용인 점과, 프로필 미로드 시의 주의.
- ServicePointManager.ServerCertificateValidationCallback - .NET 9 이후는 SocketsHttpHandler 설정에 매핑됩니다.
- dotnet list package 명령 -
--vulnerable로 알려진 취약성을 확인할 수 있습니다. - Get-AuthenticodeSignature
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows 앱에서 “관리자 권한이 필요한 처리만” 분리하는 구체적인 작성법
Windows 앱에서 UI는 asInvoker로 두고 관리자 권한이 필요한 처리만 helper EXE로 분리하는 설계를 UAC, runas, named pipe, 입력 검증까지 포함해 구체적으로 정리합니다.
Windows 앱의 기밀 정보 저장 - DPAPI로 평문 설정을 피하기
Windows 앱에서 접속 정보나 API 토큰을 설정 파일에 평문으로 저장하지 않기 위해, DPAPI / ProtectedData의 사고방식, CurrentUser와 LocalMachine의 차이, 구현 시 주의점을 정리합니다.
예기치 않은 예외가 났을 때 종료할지 계속할지 판단표
예상하지 못한 예외가 났을 때 앱을 종료할지 계속할지를, 상태 파괴·외부 부작용·스레드·네이티브 경계의 관점에서 정리합니다.
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
Windows I/O의 심층(제6회·최종회) ── 필터 드라이버와 minifilter: Procmon과 바이러스 검사가 I/O에 끼어들 수 있는 이유
Windows의 필터 드라이버와 minifilter를 그림으로 설명하는 연재의 최종회입니다. Filter Manager와 altitude, pre/post callback, Procmon과 바이러스 백신이 모든 I/O를 검사할 수 있는 구조, 「...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
권한 설계, 배포 방식, 업데이트 방식, 로그 설계까지 포함해 Windows 앱 전체를 다시 보는 이야기이므로, Windows 앱 개발과 잘 맞는 주제입니다.
기술 상담 & 설계 리뷰
기존 앱의 보안 재검토나 권한 경계 정리, 업데이터 방침 재설계부터 시작하고 싶은 경우에는 기술 상담·설계 리뷰로 정리할 수 있습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- Windows 앱 보안에서 가장 먼저 확인해야 할 것은 무엇인가요?
- 불필요한 관리자 권한을 요구하지 않는 것, 코드 서명하는 것, 비밀 정보를 평문으로 두지 않는 것, 인증서 검증을 무효화하지 않는 것, 이 네 가지입니다. 최소한의 보안은 특수한 기능을 더하는 것보다 위험한 기본 동작이나 허술한 구현을 남기지 않는 것입니다. 인증서 검증을 항상 건너뛰기, 평문 연결 문자열, 현재 디렉터리에 맡기는 DLL 로드, 문자열 연결로 SQL을 실행하는 것은 최소한의 기준에서도 피해야 할 항목입니다.
- 앱 전체를 관리자 권한으로 실행하면 안 되나요?
- 피해야 합니다. 앱 전체를 관리자 권한으로 실행하면 버그, DLL 바꿔 치기, 설정 파일 오독, 외부 입력의 허점이 그대로 강한 권한으로 실행됩니다. 일반 UI 앱은 asInvoker를 기본으로 하고, 관리자 권한이 필요한 처리만 별도 프로세스나 service로 분리해, 권한 상승은 필요한 순간에만 하는 것이 기본 방침입니다.
- API 키나 연결 문자열은 어디에 저장해야 하나요?
- 소스 코드나 appsettings.json 같은 설정 파일에 평문으로 두는 상태는 우선 벗어나야 합니다. 저장 시 기밀 정보는 용도에 따라 DPAPI / ProtectedData나 Credential Locker를 구분해 사용합니다. 또한 로그에 토큰, 비밀번호, 연결 문자열, 개인 정보를 그대로 남기면 로그 자체가 사고의 주역이 되므로 마스킹이 필요합니다.
- 사내 배포 앱에도 코드 서명이 필요한가요?
- 전제로 생각하는 편이 좋습니다. Windows 앱에서는 배포물 자체(EXE / DLL / MSI / MSIX / 자동 업데이트 모듈)가 공격 표면이 됩니다. 코드 서명+타임스탬프가 있으면 변조 탐지, 사용자에 대한 신뢰, 운영상 설명의 용이함을 얻을 수 있습니다. 업데이트 메커니즘도 업데이트 소스를 고정하고, HTTPS와 서명 확인으로 변조를 탐지할 수 있는 형태여야 합니다.