From 48df55a82e0007064bf60978bc97e4bf9aa0e7ba Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Fri, 6 Mar 2026 11:10:11 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B3=B4=EC=95=88=20=EA=B0=95=ED=99=94,?= =?UTF-8?q?=20=EC=95=88=EC=A0=95=EC=84=B1=20=EB=B0=8F=20UX=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토큰 전달 방식 변경: 명령줄 인자(-token) → 환경변수(A301_TOKEN)로 프로세스 목록 노출 방지 - 고정 설치 경로: %LOCALAPPDATA%\A301\로 런처 복사 후 레지스트리 등록 (Downloads 정리 시 깨짐 방지) - zip 추출 시 symlink 엔트리 스킵 (경로 탈출 방지) - fetchServerInfo 3회 재시도 (exponential backoff) - 다운로드 이어받기: Range 헤더 지원, 취소/오류 시 임시 파일 유지 - 416 응답 시 서버 파일 변경 감지하여 처음부터 재다운로드 - 단일 인스턴스 UX: 기존 창 FindWindow+SetForegroundWindow로 활성화 - uninstall 시 설치 디렉토리 정리 Co-Authored-By: Claude Opus 4.6 --- ANALYSIS.md | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 11 +- main.go | 134 ++++++++++++++++++++---- 3 files changed, 413 insertions(+), 20 deletions(-) create mode 100644 ANALYSIS.md diff --git a/ANALYSIS.md b/ANALYSIS.md new file mode 100644 index 0000000..de5c739 --- /dev/null +++ b/ANALYSIS.md @@ -0,0 +1,288 @@ +# A301 시스템 통합 분석 및 개선 계획 + +> 분석일: 2026-03-06 +> 대상: 런처(Go) / 서버(Go+Fiber) / 웹 클라이언트(React+Vite) + +--- + +## 1. 시스템 전체 흐름 + +``` +[웹 클라이언트] [서버] [런처] [게임 클라이언트] + | | | | + |-- POST /auth/login ----->| | | + |<-- {token, refresh} -----| | | + | | | | + |-- GET /download/info --->| | | + |<-- {url,hash,version} ---| | | + | | | | + |== a301://launch?token= =|========================>| | + | | | | + | |<-- GET /download/info --| | + | |-- {url,hash,version} -->| | + | | |-- SHA256 비교 | + | |<-- GET /download/file --| (불일치시) | + | |-- game.zip ------------>| | + | | |-- 압축 해제 | + | | |-- A301.exe -token xxx ->| + | | | | + | |<--- /internal/chain ----|-- 게임 내 API 호출 ---->| +``` + +--- + +## 2. 발견된 버그 및 문제점 + +### ~~2.1 [RESOLVED] 런처: Zip Slip 경로 검증 로직~~ + +> 재검증 결과 로직이 정상임. `!A && B`는 De Morgan 법칙에 의해 "descendant이거나 self"일 때만 허용하는 올바른 조건. + +--- + +### ~~2.2 [RESOLVED] 서버: /auth/verify Redis 세션 검증~~ + +> 재검증 결과 `service.go:212-216`에서 이미 Redis 세션을 확인하고 있음. + +--- + +### 2.3 [HIGH] 런처: 서버 fileHash 비어있으면 검증 우회 + +**위치**: `launcher/main.go` ensureGame() +**문제**: `serverInfo.FileHash == ""`이면 해시 비교를 건너뛰고 기존 파일을 그대로 사용. 서버에서 zip에 A301.exe가 없으면 fileHash가 빈 문자열로 저장됨. + +**연쇄 시나리오**: +1. 관리자가 A301.exe 없는 zip 업로드 → 서버 fileHash = "" +2. 런처가 info 조회 → hash 빈 문자열 → 검증 스킵 +3. 로컬 A301.exe가 어떤 파일이든 실행됨 + +**수정안**: +- 서버: zip에 A301.exe 없으면 업로드 거부 (현재는 빈 해시 허용) +- 런처: fileHash가 비어있으면 에러 처리 + +**심각도**: HIGH (보안/무결성) + +--- + +### 2.4 [HIGH] 런처: 토큰이 프로세스 명령줄에 노출 + +**위치**: `launcher/main.go` handleURI() +**문제**: `A301.exe -token `로 실행 → tasklist, Process Explorer 등에서 토큰 노출 + +**수정안**: +- 환경변수로 전달: `os.Setenv("A301_TOKEN", token)` 후 실행 +- 또는 임시 파일에 토큰 쓰고 파일 경로 전달 + +**심각도**: HIGH (보안) + +--- + +### 2.5 [HIGH] 블록체인 트랜잭션 멱등성 미보장 + +**위치**: `server/internal/chain/service.go` 모든 트랜잭션 함수 +**문제**: 동일 요청 재전송 시 중복 처리 (토큰 이중 전송, NFT 이중 민팅) +- 네트워크 타임아웃 후 클라이언트 재시도 시 발생 가능 + +**수정안**: +- 클라이언트에서 idempotency key 전송 +- 서버에서 Redis로 중복 요청 감지 (TTL 기반) + +**심각도**: HIGH (데이터 무결성) + +--- + +### 2.6 [HIGH] 서버: Rate Limiting 없음 + +**위치**: 전체 API +**문제**: 토큰 리프레시, 로그인, 다운로드 등 모든 엔드포인트에 속도 제한 없음. +- 브루트포스 로그인 공격 가능 +- 리프레시 토큰 재사용 공격 가능 +- 다운로드 대역폭 남용 가능 + +**수정안**: Fiber 미들웨어로 rate limiter 추가 (IP 기반 + 토큰 기반) + +**심각도**: HIGH (보안) + +--- + +### 2.7 [MEDIUM] 웹 클라: 관리자 권한 클라이언트 사이드만 검증 + +**위치**: `client/src/components/AdminRoute.jsx` +**문제**: localStorage의 `role`을 조작하면 관리자 UI 접근 가능. API 호출은 서버에서 차단되지만, UI 자체가 노출됨. + +**영향**: 관리자 기능 목록/구조가 일반 사용자에게 보임 + +**수정안**: 서버 API가 이미 권한 체크하므로 실질적 위험은 낮음. 다만 관리자 라우트 로딩 시 서버에서 role 재확인하면 더 안전. + +**심각도**: MEDIUM (UX/보안) + +--- + +### 2.8 [MEDIUM] 런처: Symlink 공격 미차단 + +**위치**: `launcher/main.go` extractZip() +**문제**: zip 내 심볼릭 링크를 통한 디렉토리 탈출 미검증 +- `filepath.Clean()`은 심볼릭 링크를 해석하지 않음 + +**수정안**: zip 엔트리의 파일 모드에서 symlink 비트 확인 → 거부 + +**심각도**: MEDIUM (보안) + +--- + +### 2.9 [MEDIUM] 런처: 네트워크 재시도 로직 없음 + +**위치**: `launcher/main.go` fetchServerInfo(), doDownload() +**문제**: 일시적 네트워크 오류 시 즉시 실패. 다운로드 99%에서 끊기면 처음부터 재시작. + +**수정안**: +- fetchServerInfo: 3회 재시도 (exponential backoff) +- doDownload: Range 헤더 지원으로 이어받기 구현 + +**심각도**: MEDIUM (안정성/UX) + +--- + +### 2.10 [MEDIUM] 런처: 단일 인스턴스 체크 시 사용자 피드백 없음 + +**위치**: `launcher/main.go` main() mutex 체크 +**문제**: 이미 실행 중이면 조용히 종료 → 사용자는 클릭했는데 아무 반응 없음 + +**수정안**: 기존 인스턴스 창을 foreground로 가져오기 (`FindWindow` + `SetForegroundWindow`) + +**심각도**: MEDIUM (UX) + +--- + +### 2.11 [MEDIUM] 웹 클라: 다운로드 정보 실패 시 무음 처리 + +**위치**: `client/src/components/DownloadSection.jsx` +**문제**: `/api/download/info` 실패 시 console.error도 없이 "런처 준비 중" 메시지 표시. 디버깅 불가. + +**수정안**: 에러 로깅 추가 + 재시도 버튼 제공 + +**심각도**: MEDIUM (UX/디버깅) + +--- + +### 2.12 [MEDIUM] 서버: 파일 업로드 크기 제한 없음 + +**위치**: `server/internal/download/handler.go` +**문제**: StreamRequestBody 활성화되어 있지만 별도 크기 제한 없음. 수십 GB 파일 업로드 시 디스크 고갈 가능. + +**수정안**: Fiber의 BodyLimit 미들웨어 적용 (예: 4GB) + +**심각도**: MEDIUM (안정성) + +--- + +### 2.13 [LOW] 서버: Username 대소문자 구분 + +**위치**: `server/internal/auth/model.go` +**문제**: "User"와 "user"가 별도 계정으로 등록 가능. 블록체인 username 조회 시 혼동 가능. + +**수정안**: 등록/로그인 시 username을 항상 소문자로 정규화 + +**심각도**: LOW (데이터 일관성) + +--- + +### 2.14 [LOW] 서버: Wallet 암호화 키 시작 시 미검증 + +**위치**: `server/internal/chain/service.go` +**문제**: WALLET_ENCRYPTION_KEY가 잘못되면 런타임에 지갑 복호화 실패. 서버 시작 시 검증하지 않음. + +**수정안**: 서비스 초기화 시 키 길이/형식 검증 (64 hex = 32 bytes) → fail fast + +**심각도**: LOW (운영 안정성) + +--- + +### 2.15 [LOW] 웹 클라: 비밀번호 검증이 약함 + +**위치**: `client/src/pages/RegisterPage.jsx` +**문제**: 6자 이상만 체크. 복잡도 요구 없음. + +**수정안**: 서버 측 검증에 의존하더라도, 클라이언트에서도 실시간 피드백 제공 + +**심각도**: LOW (보안/UX) + +--- + +## 3. 통합 흐름 검증 결과 + +### 3.1 정상 시나리오 (Happy Path) - OK + +| 단계 | 동작 | 상태 | +|------|------|------| +| 웹 로그인 | POST /auth/login → JWT 발급 | OK | +| 다운로드 정보 | GET /download/info → hash/url/version | OK | +| 런처 호출 | a301://launch?token=xxx | OK | +| 런처 서버 조회 | GET /download/info | OK | +| 해시 비교 | 로컬 A301.exe SHA256 vs 서버 fileHash | OK | +| 다운로드 | GET /download/file → zip | OK | +| 압축 해제 | extractZip → moveContents | OK | +| 게임 실행 | A301.exe -token xxx | OK | +| 게임→서버 | /internal/chain/* (X-API-Key) | OK | + +### 3.2 엣지 케이스 검증 + +| 시나리오 | 기대 동작 | 실제 동작 | 판정 | +|----------|-----------|-----------|------| +| 서버 다운 시 런처 호출 | 에러 표시 | MessageBox 표시 후 종료 | OK | +| 토큰 만료 후 게임 시작 | 리프레시 후 전달 | 웹 클라: localStorage에서 읽음 (최신 토큰) | PARTIAL - 리프레시 타이밍에 따라 만료 토큰 전달 가능 | +| 다운로드 중 네트워크 끊김 | 재시도 또는 복구 | 즉시 실패, 재시작 필요 | FAIL | +| 동시에 런처 2개 실행 | 하나만 실행 | Mutex로 차단, 피드백 없음 | PARTIAL | +| A301.exe 없는 zip 업로드 | 업로드 거부 | ~~fileHash="" 로 저장~~ → 업로드 거부 (수정됨) | **FIXED** | +| 로그아웃 후 토큰으로 verify | 무효 판정 | Redis 세션 확인으로 무효 판정 (기존 정상) | OK | +| 관리자가 대용량 파일 업로드 | 크기 제한 | 제한 없음 | FAIL | +| 블록체인 트랜잭션 중복 요청 | 한 번만 처리 | 중복 처리됨 | FAIL | + +--- + +## 4. 개선 우선순위 로드맵 + +### Phase 1: 보안 긴급 패치 + +| # | 항목 | 대상 | 상태 | +|---|------|------|------| +| 1 | ~~Zip Slip 검증 로직 수정~~ | 런처 | 재검증: 정상 | +| 2 | ~~/verify Redis 세션 확인 추가~~ | 서버 | 재검증: 이미 구현됨 | +| 3 | fileHash 빈 문자열 시 업로드 거부 | 서버 | **완료** | +| 4 | 토큰 전달 방식 변경 (명령줄 → 환경변수) | 런처 | **완료** | +| 5 | Rate limiting 추가 | 서버 | **완료** | +| 6 | Symlink 차단 | 런처 | **완료** | + +### Phase 2: 안정성 개선 + +| # | 항목 | 대상 | 상태 | +|---|------|------|------| +| 7 | 네트워크 재시도 로직 (fetchServerInfo) | 런처 | **완료** | +| 8 | 블록체인 트랜잭션 멱등성 키 | 서버 | **완료** | +| 9 | 파일 업로드 크기 제한 | 서버 | **완료** | +| 10 | 런처 단일 인스턴스 UX 개선 | 런처 | **완료** | + +### Phase 3: UX 개선 + +| # | 항목 | 대상 | 상태 | +|---|------|------|------| +| 11 | 다운로드 정보 실패 시 재시도 버튼 | 웹 클라 | **완료** | +| 12 | 비밀번호 복잡도 실시간 피드백 | 웹 클라 | **완료** | +| 13 | Username 대소문자 정규화 | 서버 | **완료** | +| 14 | Wallet 암호화 키 시작 시 검증 | 서버 | 이미 구현됨 | +| 15 | 다운로드 이어받기 (Range 헤더) | 런처 | **완료** | + +--- + +## 5. 컴포넌트별 요약 + +### 런처 (Go/Win32) +- **강점**: DPI 인식, 원자적 파일 이동, 다운로드 진행 UI, 2GB 크기 제한 +- **약점**: Zip Slip 검증 오류, 토큰 노출, 재시도 없음, symlink 미차단 + +### 서버 (Go/Fiber) +- **강점**: 모듈화된 구조, JWT+Redis 세션, AES-256 지갑 암호화, 원자적 파일 업로드 +- **약점**: verify 세션 미검증, rate limiting 없음, 트랜잭션 멱등성 없음, 빈 해시 허용 + +### 웹 클라이언트 (React/Vite) +- **강점**: 토큰 자동 리프레시, 관리자 UI 분리, 다크 테마, SPA 라우팅 +- **약점**: 에러 피드백 부족, 관리자 클라이언트 사이드만 검증, 약한 비밀번호 정책 diff --git a/CLAUDE.md b/CLAUDE.md index 2793db5..e0bb6d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,16 @@ gameExeName = "A301.exe" // 기술 식별자 — 게임 표기명과 별개 protocolName = "a301" // 기술 식별자 ``` +## Install Location + +- `install()` 시 런처를 `%LOCALAPPDATA%\A301\launcher.exe`로 복사 후 해당 경로를 레지스트리에 등록. +- 게임 파일(`A301.exe` 등)도 `%LOCALAPPDATA%\A301\`에 설치됨. +- 사용자가 원본 다운로드 파일을 삭제해도 프로토콜 핸들러가 정상 동작. +- 토큰은 명령줄이 아닌 `A301_TOKEN` 환경변수로 게임에 전달. + ## Notes -- `extractZip()` — zip 내 최상위 디렉토리 1단계 제거 후 추출. `launcher.exe` 자신은 덮어쓰기 방지. +- `extractZip()` — zip 내 최상위 디렉토리 1단계 제거 후 추출. `launcher.exe` 자신은 덮어쓰기 방지. Symlink 엔트리는 스킵. - 레지스트리는 `HKCU` (현재 사용자) 에만 쓰므로 관리자 권한 불필요. +- `fetchServerInfo()` — 3회 재시도 (exponential backoff). +- `doDownload()` — Range 헤더로 이어받기 지원. 취소/오류 시 임시 파일 유지. diff --git a/main.go b/main.go index bf822af..01360bf 100644 --- a/main.go +++ b/main.go @@ -132,6 +132,8 @@ var ( getSystemMetricsProc = user32.NewProc("GetSystemMetrics") getDpiForSystemProc = user32.NewProc("GetDpiForSystem") setProcessDpiAwarenessContextProc = user32.NewProc("SetProcessDpiAwarenessContext") + findWindowWProc = user32.NewProc("FindWindowW") + setForegroundWindowProc = user32.NewProc("SetForegroundWindow") createMutexWProc = kernel32.NewProc("CreateMutexW") getModuleHandleWProc = kernel32.NewProc("GetModuleHandleW") createFontIndirectWProc = gdi32.NewProc("CreateFontIndirectW") @@ -424,41 +426,71 @@ func downloadWithProgress(downloadURL, destDir string) error { } func doDownload(downloadURL, destDir string) error { - resp, err := downloadClient.Get(downloadURL) + tmpPath := filepath.Join(os.TempDir(), "a301_game.zip") + + // 이어받기: 기존 임시 파일 크기 확인 + var resumeOffset int64 + if fi, err := os.Stat(tmpPath); err == nil { + resumeOffset = fi.Size() + } + + req, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + return fmt.Errorf("다운로드 요청 생성 실패: %w", err) + } + if resumeOffset > 0 { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset)) + } + + resp, err := downloadClient.Do(req) if err != nil { return fmt.Errorf("다운로드 연결 실패: %w", err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { + var downloaded int64 + var total int64 + var tmpFile *os.File + + switch resp.StatusCode { + case http.StatusPartialContent: + // 서버가 Range 요청 수락 → 이어받기 + downloaded = resumeOffset + total = resumeOffset + resp.ContentLength + tmpFile, err = os.OpenFile(tmpPath, os.O_WRONLY|os.O_APPEND, 0644) + case http.StatusOK: + // 서버가 Range 미지원이거나 파일이 변경됨 → 처음부터 + total = resp.ContentLength + tmpFile, err = os.Create(tmpPath) + case http.StatusRequestedRangeNotSatisfiable: + // 임시 파일이 서버 파일보다 큼 (서버 파일 변경) → 처음부터 + os.Remove(tmpPath) + return doDownload(downloadURL, destDir) + default: return fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode) } + if err != nil { + return fmt.Errorf("임시 파일 열기 실패: %w", err) + } - total := resp.ContentLength if total > maxDownloadSize { + tmpFile.Close() + os.Remove(tmpPath) return fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total) } - tmpPath := filepath.Join(os.TempDir(), "a301_game.zip") - tmpFile, err := os.Create(tmpPath) - if err != nil { - return fmt.Errorf("임시 파일 생성 실패: %w", err) - } - - var downloaded int64 buf := make([]byte, 32*1024) for { if downloadCancelled.Load() { tmpFile.Close() - os.Remove(tmpPath) + // 취소 시 임시 파일 유지 (이어받기 가능) return fmt.Errorf("다운로드가 취소되었습니다") } n, err := resp.Body.Read(buf) if n > 0 { if _, werr := tmpFile.Write(buf[:n]); werr != nil { tmpFile.Close() - os.Remove(tmpPath) return fmt.Errorf("파일 쓰기 실패: %w", werr) } downloaded += int64(n) @@ -477,7 +509,7 @@ func doDownload(downloadURL, destDir string) error { } if err != nil { tmpFile.Close() - os.Remove(tmpPath) + // 네트워크 오류 시 임시 파일 유지 (이어받기 가능) return fmt.Errorf("다운로드 중 오류: %w", err) } } @@ -542,6 +574,11 @@ func extractZip(zipPath, destDir string) error { return fmt.Errorf("잘못된 zip 경로: %s", rel) } + // Symlink 차단: zip 내 심볼릭 링크를 통한 경로 탈출 방지 + if f.FileInfo().Mode()&os.ModeSymlink != 0 { + continue + } + if f.FileInfo().IsDir() { os.MkdirAll(dest, 0755) continue @@ -621,7 +658,7 @@ type downloadInfo struct { URL string `json:"url"` } -func fetchServerInfo() (*downloadInfo, error) { +func fetchServerInfoOnce() (*downloadInfo, error) { resp, err := apiClient.Get(serverInfoURL) if err != nil { return nil, fmt.Errorf("서버 연결 실패: %w", err) @@ -642,6 +679,24 @@ func fetchServerInfo() (*downloadInfo, error) { return &info, nil } +func fetchServerInfo() (*downloadInfo, error) { + const maxRetries = 3 + var lastErr error + for i := range maxRetries { + info, err := fetchServerInfoOnce() + if err == nil { + return info, nil + } + lastErr = err + // 4xx 에러는 재시도해도 의미 없음 + if strings.Contains(err.Error(), "서버 오류") || strings.Contains(err.Error(), "준비되지") { + return nil, err + } + time.Sleep(time.Duration(1<