728x90

1. 도입: 카메라가 왜 갑자기 보안 이슈가 되는가

요즘 엔터프라이즈 CCTV는 예전처럼 "화면 찍어서 NVR에 던지는 상자"가 아니다. AXIS가 자사 카메라에서 리눅스 애플리케이션을 직접 돌릴 수 있게 밀고 있는 것처럼, 카메라 자체가 리눅스가 돌아가는 엣지 노드다. 즉 패치 관리, 크리덴셜 관리, 취약점 스캐닝 대상이 되는 서버 한 대라는 얘기다. 그런데 실무에서 우리는 이 기기들을 "그냥 벽에 붙은 카메라" 취급하고 방화벽 뒤에 던져두는 경우가 많다.

이번에 화제가 된 사례는 한화비전(구 삼성테크윈) 카메라 펌웨어에서 GitHub admin 권한 토큰이 발견된 건이다. 그것도 로그인 UI 파일 안에, 그리고 조직의 수백 개 리포지토리에 접근 가능한 관리자 토큰이. 이게 남 일 같지 않은 이유는, 여기서 벌어진 실수가 우리 CI 파이프라인에서도 똑같이 벌어질 수 있는 구조적 실수이기 때문이다. 오늘은 이 사건을 훑으면서 "펌웨어에서 시크릿을 어떻게 찾는지", "왜 이런 게 반복되는지", "그래서 우리 파이프라인은 어떻게 막아야 하는지"까지 실무 관점으로 정리해본다.

2. 핵심: 펌웨어에서 토큰이 나오기까지의 흐름

원문 저자가 밟은 경로를 실무 순서대로 재구성하면 이렇다. 이 흐름 자체가 "서드파티 하드웨어 신뢰성 검증"의 기본기라 알아두면 유용하다.

2-1. 펌웨어 blob 확보 → binwalk로 열어보기

제조사 사이트에서 모델별 펌웨어를 그냥 다운받을 수 있는 경우가 의외로 많다. 받은 이미지를 일단 binwalk로 던져서 안에 뭐가 들었는지 본다.

$ binwalk firmware.bin

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             POSIX tar archive (GNU)
1245184       0x130000        gzip compressed data
...
$ binwalk -e firmware.bin   # 추출까지

여기서 저자는 내부에 AI 관련 tarball과 fwimage.tgz가 있었는데, 이게 암호화되어 있어서 binwalk가 "encrypted"로 플래그를 띄웠다고 한다. 여기까지가 1차 관문이다.

2-2. 암호화 계층 뚫기

흥미로운 건 복호화 방식이다. 저자에 따르면 fwupgrader 바이너리 안에서:

  • AES 키가 바이너리 내부의 작은 static key table과 XOR 되어 있고, 런타임에 재조립된다.
  • IV는 그냥 평문으로 박혀 있다.
  • fwupgrader가 openssl CLI로 shell out 하는데, 그 명령어 조각들조차 같은 방식으로 XOR 난독화되어 있다.

재구성된 복호화 명령은 이런 형태였다고 한다:

openssl enc -md sha256 -aes-256-cbc -d \
  -K KEY -iv IV -in INPUT -out OUTPUT

여기서 핵심 포인트는 키가 모델 라인 전체에 걸쳐 하드코딩되어 동일하다는 것. 이게 왜 문제냐면, 하드웨어에 키를 구워 넣는(fuse) 방식과 달리 기기를 소유하지 않아도 펌웨어만 있으면 누구나 복호화 가능하다는 뜻이기 때문이다. 소위 "burn key into hardware"가 완벽한 방어는 아니지만, 최소한 "카메라를 물리적으로 가진 사람만" 이라는 진입 장벽이라도 생긴다. 반면 정적 키는 그 장벽이 0이다.

2-3. rootfs 확보 후 trufflehog로 시크릿 스캔

복호화된 rootfs가 손에 들어오면, 그 다음은 우리 실무에서도 매일 쓰는 도구가 나온다. trufflehog다.

$ trufflehog filesystem ./rootfs --only-verified

🐷🔑🐷  TruffleHog. Unearth your secrets. 🐷🔑🐷

Found verified result 🐷🔑
Detector Type: Github
Decoder Type: PLAIN
Raw result: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
File: ./rootfs/var/www/admin/assets/index-a1b2c3.js
Rotation_guide: https://howtorotate.com/docs/tutorials/github/

저자는 이 토큰이 약 30개 파일에 중복되어 있었고, 확인해보니 조직 내 수백 개 리포지토리에 admin 권한을 가진 토큰이었다고 한다. 즉 이 토큰 하나면 그 조직 GitHub의 사실상 전권을 쥘 수 있었다는 뜻이다.

2-4. 왜 30개 파일에 중복됐나 — 진짜 범인

이 부분이 이 글에서 가장 실무적으로 중요하다. 저자가 파악한 원인은 Vite 빌드 설정 실수였다. UI를 Vite로 빌드하는데, 어떤 변수에 process.env 전체를 통째로 주입하도록 설정되어 있었던 것.

var W = {
  DATAPORT: 9090,
  GIT_LFS_SKIP_SMUDGE: 1,
  npm_command: "run-script",
  KUBERNETES_SERVICE_PORT_HTTPS: 443,
  GITHUB_NPM_TOKEN: "ghp_...REDACTED...",
  npm_config_userconfig: "/home/docker/.npmrc",
  // ...
}

결과적으로 CI 잡의 환경변수 전체가 프론트엔드 번들 파일에 그대로 박혀서 출하된 것이다. 카메라 admin UI에 접속하는 사람은 이 토큰을 네트워크 너머로 전송받았을 가능성이 있다는 뜻. 덤으로 미국 국방부(DoD)에 할당된 IP 대역이 내부 서비스 env에 박혀 있는 것도 발견됐는데(한화가 방산 계열사를 두고 있는 점과 엮여 흥미로운 추측을 낳긴 했지만) 이 부분은 저자도 "SPECULATION"이라고 명시했으니 우리도 추측으로만 남겨두자.

참고로 대응은 빨랐다. 저자가 신고 메일을 보냈고 한화 측은 12시간 내에 토큰을 폐기했다고 한다. 사고 자체는 나쁘지만 대응 속도는 모범적이었다.

3. 실무 관점: 왜 반복되고, 우리는 어떻게 막나

3-1. 이 실수가 반복되는 근본 패턴

"토큰을 코드에 하드코딩하지 마라"는 다 안다. 그런데도 계속 터진다. 이유는 개발자가 손으로 넣는 하드코딩이 아니라 빌드 도구가 자동으로 환경변수를 삼켜버리는 구조이기 때문이다. 이번 케이스가 정확히 그렇다. 흔한 함정 몇 가지:

  • 프론트엔드 번들에 env 통주입: Vite/Webpack에서 define이나 DefinePluginprocess.env를 통째로 넣는 실수. Vite는 원래 VITE_ 프리픽스 붙은 것만 클라이언트에 노출하는데, 이걸 우회해서 전체를 넣으면 서버 시크릿까지 클라이언트로 샌다.
  • CI 러너 env에 장기 토큰 상주: GitHub Actions/GitLab CI 러너 환경에 오래 사는 PAT를 넣어두면, 빌드 산출물 어딘가로 새기 쉽다.
  • admin 권한 토큰 사용: npm private 패키지 하나 받자고 조직 admin 권한 PAT를 쓰는 건 최악. 필요한 최소 권한(read:packages 정도)만 줘야 한다.

3-2. 그래서 우리 CI에 시크릿 스캐너를 붙인다

가장 먼저 할 일은 "새어나가기 전에 잡는" 파이프라인 게이트다. GitHub Actions 예시:

name: secret-scan
on: [push, pull_request]

jobs:
  trufflehog:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 전체 히스토리 스캔
      - name: TruffleHog
        uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

커밋 들어가기 전 로컬에서 막고 싶으면 pre-commit 훅으로 gitleaks를 건다:

$ gitleaks detect --source . -v

    ○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

Finding:     GITHUB_NPM_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
Secret:      ghp_xxxxxxxxxxxxxxxxxxxx
RuleID:      github-pat
File:        web/.env.production
Commit:      a1b2c3d
Author:      dev@example.com

WARN[0000] leaks found: 1

3-3. 흔한 함정 — 실제로 만나는 에러

스캐너를 붙이면 처음엔 오탐과 씨름한다. 특히 CI에서 히스토리가 얕게 clone되면 이런 에러를 만난다:

Error: TruffleHog scan failed: unable to scan commits:
git rev-parse HEAD~1: fatal: ambiguous argument 'HEAD~1':
unknown revision or path not in the working tree

원인은 대부분 actions/checkout의 기본 fetch-depth: 1 때문이다. diff 기반 스캔을 하려는데 이전 커밋이 없어서 터지는 것. 위 예시처럼 fetch-depth: 0으로 전체 히스토리를 가져오면 해결된다. 다만 리포가 크면 clone 시간이 늘어나니, PR 스캔은 base...head 범위만 스캔하도록 좁히는 게 낫다.

또 하나 자주 보는 오탐 관련 이슈:

gitleaks detect --config .gitleaks.toml
WARN leaks found: 47

테스트 픽스처나 예제 코드의 더미 값까지 다 잡혀서 47개가 뜨는 경우다. 이때 CI를 통째로 빨갛게 만들지 말고, .gitleaks.toml[allowlist]에 파일 경로/정규식을 명시적으로 등록해서 걸러야 한다. 무작정 스캐너를 끄면 진짜 시크릿도 같이 놓친다.

3-4. 공급망 관점 — 서드파티 하드웨어 도입 시 체크

이번 사건의 진짜 교훈은 "우리가 산 카메라도 이럴 수 있다"는 것이다. IoT/OT 기기를 도입할 때 실무에서 확인할 것:

  • 펌웨어가 공개 다운로드 가능한가? 가능하다면 우리도 binwalk + trufflehog로 한 번 열어보는 게 좋다(계약/법적 검토는 별도).
  • 기기가 나가는 아웃바운드 트래픽 통제: 카메라는 원칙적으로 내부 세그먼트에 격리하고, 필요한 목적지 외에는 egress를 막는다. 이번처럼 admin UI가 토큰을 브라우저로 뿌려도, 세그먼트 격리가 되어 있으면 피해 범위가 줄어든다.
  • 펌웨어 업데이트 검증: 서명 검증 없이 업데이트를 받으면, 하드코딩 키를 아는 공격자가 악성 펌웨어를 밀어넣을 여지가 생긴다.

3-5. 재발 방지 아키텍처 — 시크릿을 코드/이미지 밖으로

근본 해법은 "빌드 산출물에 시크릿이 아예 존재하지 않게" 만드는 것이다. 트레이드오프를 곁들여 비교하면:

  • SOPS + age/KMS: 시크릿을 암호화해서 git에 커밋. 관리 단순, GitOps와 궁합 좋음. 단, 복호화 키 관리가 여전히 숙제다.
  • HashiCorp Vault: 동적 시크릿·짧은 TTL 발급 가능. 이번 사건처럼 장기 admin 토큰을 아예 없앨 수 있다. 단, Vault 자체 운영 부담이 크다.
  • External Secrets Operator(ESO): 쿠버네티스 환경이면 AWS Secrets Manager/Vault의 시크릿을 K8s Secret으로 동기화. CI/런타임이 클러스터 위라면 가장 매� 끄럽다.

Kubernetes 환경이라면 ESO로 이렇게 선언한다:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: github-npm-token
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: github-npm-token
  data:
    - secretKey: GITHUB_NPM_TOKEN
      remoteRef:
        key: ci/npm
        property: token

핵심은 토큰이 Vault에만 있고, 필요한 순간 짧은 수명으로만 주입된다는 점이다. 빌드 산출물이나 이미지 레이어에는 절대 남지 않는다. 그리고 원칙적으로 발급하는 토큰 권한을 최소화해야 한다. npm 패키지 읽기용이면 admin이 아니라 read 스코프면 충분하다.

4. 정리: 한 줄 요약과 적용 대상

한 줄 요약: 이번 사건은 "개발자가 토큰을 하드코딩한" 사건이 아니라 "빌드 도구가 CI 환경변수를 통째로 산출물에 삼킨" 구조적 사고다. 그래서 사람 교육만으로는 안 막히고, 파이프라인 게이트(스캐너) + 시크릿 외부화(Vault/ESO/SOPS) + 최소 권한 세 가지가 동시에 필요하다.

  • 지금 당장 할 것: gitleaks/trufflehog를 CI에 게이트로 붙이고, 프론트엔드 빌드에서 process.env 통주입이 없는지 확인한다.
  • IoT/OT 기기 담당자: 도입 전 펌웨어를 한 번 열어보고, 기기 egress를 세그먼트로 격리한다.
  • 플랫폼/DevOps 팀: 장기 PAT를 Vault 동적 시크릿이나 ESO로 대체하고, 토큰 권한을 최소 스코프로 재발급한다.

스캐너는 최후의 방어선이지 첫 방어선이 아니다. 첫 방어선은 "시크릿이 애초에 이미지·번들에 들어갈 경로 자체를 없애는" 아키텍처다.

참고 자료

728x90

+ Recent posts