유니티 빌드 파이프라인 5 - 보안 점검과 수정

이번 글에서는 아래 내용을 정리한다.

  • 점검을 어떻게 했나
  • 발견한 다섯 가지 치명 항목과 각각의 원인
  • 각각을 어떻게 고치고 무엇으로 확인했나
  • 점검에서 이상 없었던 것들
  • 남겨 둔 것

점검 방법

설정 파일을 믿지 않고 실제 상태를 봤다. 네 대의 서버에서 읽기 전용 스크립트를 돌려 메모리와 디스크, 방화벽 규칙과 실제 포트 도달 여부, 파일 권한, Forgejo의 팀과 협업자와 브랜치 보호와 토큰 범위를 뽑았다. 파이프라인 코드는 경합 조건을 염두에 두고 읽었다. 배포된 게임 파일은 64자리 16진수 문자열을 찾는 정규식으로 훑어 그 지문을 키의 지문과 비교했다.

발견한 것과 고친 것

1. 2019 서버의 SMB가 내부망 전체에 열려 있었다

첫 설계 때 배포 서버에 DeployOut 공유를 만들었다. Windows는 공유를 만들면 파일 서버 역할을 설치하고 파일 및 프린터 공유(SMB-In) 규칙을 모든 주소 대상으로 켠다. 이 서버의 네트워크 프로필이 Private이라 그 규칙이 살았다. 공유를 만들기 전에는 445가 닫혀 있었는데, 점검 때 다른 VM에서 접속해 보니 열려 있었다. 공유기에 445 포워딩은 없어 인터넷 노출은 아니었지만, 인터넷에 열린 서버의 SMB가 내부 기기 전체에 열린 상태였다.

공유는 배포 에이전트가 서버 안에서 돌게 되면서 쓰이지 않게 된 것이라 삭제했다. 내가 넣은 445 허용 규칙을 지우고, 역할 설치가 켠 규칙 두 개를 비활성화했다. 다른 VM에서 다시 접속해 닫힌 것을 확인했다.

New-SmbShare 한 줄이 방화벽을 연다는 것을 이번에 배웠다. 공유를 만든 뒤에는 445 규칙의 원격 주소 범위를 확인하고, 안 쓰게 된 공유는 지우고 규칙도 되돌려야 한다.

2. 암호화 키가 배포 파일에 문자열로 들어 있었다

키를 C#의 const string으로 주입했다. IL2CPP는 문자열 리터럴을 global-metadata.dat에 그대로 남긴다. 배포된 release 빌드에서 64자리 16진수 문자열이 정확히 하나 나왔고, 그 지문이 운영 키의 지문과 같았다. 로더가 평문 파일도 그대로 읽는 것도 같은 문제였다. 키가 없어도 평문으로 바꿔치기하면 검증을 우회할 수 있었다.

세 가지를 했다. 빌드가 키를 무작위 마스크와 XOR한 byte 배열 조각 8개로 생성해 넣고 실행 시 조합한다. 로더는 에디터와 개발 빌드에서만 평문을 허용하고 릴리즈는 거부한다. 그리고 키를 교체했다. 새 빌드의 배포 파일을 같은 정규식으로 훑으니 키 문자열이 없고, 배포된 데이터는 새 키로만 열리고 옛 키로는 HMAC 검증에 실패했다.

덧붙여 release 빌드는 이제 빌드 단계에서 데이터를 암호화한다. 배포 Job의 암호화 단계는 빠진 파일을 채우는 안전장치로 남는다. 빌드가 끝나면 작업 공간의 키 파일을 개발용으로 되돌려, 실제 키가 디스크에 남지 않게 했다.

3. 빌드와 배포의 복사 경합

빌드 Job은 _exeOut을 지우고 다시 채우고, 배포 Job은 _exeOut을 두 번(선별 복사 때, 백업 때) 읽었다. 두 Job 사이에 잠금이 없어, 복사 중에 배포를 누르거나 복사가 중간에 실패하면 불완전한 파일이 유저 저장소에 올라갈 수 있었다. 재현하지는 않았고 코드 순서를 보고 판단했다.

빌드는 임시 폴더에 다 채운 뒤 이름을 바꾸고 완료 표식을 마지막에 쓴다. 배포는 스냅샷을 한 번만 뜨고 표식이 복사 전후로 같은지 확인한 뒤 그 스냅샷만 쓴다. 3편에 자세히 적었다.

4. 파이프라인 코드를 바꿀 수 있는 계정이 너무 많았다

빌드 에이전트와 배포 에이전트에서 무엇이 실행될지는 ci 저장소가 정한다. 여기에 쓸 수 있는 계정이 셋이었다. 나, 기획자, 그리고 CI 계정. 기획자는 조직 전체 쓰기 팀에 있어 ci와 배포 저장소 권한을 자동으로 받았고, CI 계정은 CI 저장소에 파일을 넣을 때 편의로 준 협업자 쓰기 권한이 남아 있었다. CI 계정의 토큰은 배포 때마다 외부에 열린 2019 서버로 내려간다. 그 서버가 뚫리면 공격자가 ci에 커밋하고, 다음 빌드 때 작업 VM에서 그 코드가 실행된다.

CI 계정의 ci 쓰기 권한을 뺐다. 기획자 팀은 게임 저장소만 명시적으로 넣고 ci와 배포 저장소는 뺐다. main 브랜치는 push 허용 목록을 켜서 ci는 관리자와 자동화 계정만, 배포 저장소는 거기에 CI 계정을 더한 계정만 push할 수 있다. 자동 등록 스크립트는 새 게임 저장소를 기획자 팀에 추가하고, 새 배포 저장소에 같은 보호를 건다.

CI 저장소는 곧 원격 코드 실행 권한이다. 파이프라인 정의를 바꿀 수 있는 계정은 모든 에이전트에서 코드를 실행할 수 있는 계정이다. 쓰기 권한을 최소로 두고, 에이전트에 내려가는 토큰은 그 저장소에 쓰기 권한이 없어야 한다.

5. 자동화가 지울 예정이던 자격증명에 매달려 있었다

프로젝트 목록 동기화와 CI 저장소 커밋이, 설치 작업용으로 만들어 두고 나중에 지운다고 한 관리자 토큰과 젠킨스 계정을 쓰고 있었다. 하나라도 지우면 목록 갱신이 알림 없이 멈춘다.

Forgejo에 조직 소유자(사이트 관리자는 아닌) 서비스 계정을 만들어 전용 토큰을 발급하고, 젠킨스에도 자동화 전용 계정을 만들었다. 스크립트는 이 둘만 쓴다. 설치용 자격증명은 이제 지워도 된다.

이상 없었던 것

항목 결과
컨트롤러 VM 메모리와 디스크 가용 2.3GB, OOM 없음, 디스크 사용률 최대 53%
방화벽과 젠킨스 에이전트 포트 재부팅 후에도 유지, 인바운드 에이전트 포트는 닫힘(WebSocket만)
키와 토큰 파일 권한 모두 root 전용
산출물 폴더 노출 FTPS나 IIS 어디에도 연결되지 않음
작업 VM 원격 접근, 서비스 계정 내부망에서 SMB 접근 불가, 서비스 계정은 어디서도 관리자 아님

남겨 둔 것

  • 배포 저장소의 이력은 전부 테스트다(한 버전은 평문, 여러 버전은 옛 키). 연습용 프로젝트라 정리하지 않았다. 실제 게임은 저장소를 새로 만들 때 배포 저장소도 새로 생기니 처음부터 깨끗하다.
  • exeOut 백업 폴더에는 보존 기한이 없다. 수동 정리다.
  • 서비스 래퍼(WinSW) 실행 파일은 서명이 없고 GitHub도 그 버전의 해시를 제공하지 않아 따로 검증하지 못했다.
  • 내일 새벽 백업은 젠킨스가 떠 있는 상태의 첫 실행이다. 결과를 본다.

마치며

하루에 만든 것을 하루에 점검했더니 다섯 개가 나왔다. 그중 둘은 내가 그날 만든 것이었다. 설정을 기억하는 동안, 되돌리기 쉬운 동안 점검하는 것이 가장 싸다. SMB와 키 문자열은 며칠 뒤에 발견했다면 훨씬 불편했을 것이다. 파이프라인은 편의를 위해 만든 것이지만 그 편의는 곧 권한이다. 이제 남은 것은 실제 게임 저장소를 만들고 이 체계에 올리는 일이다.