
Stratagems #17: Alex가 AI 미끼를 던지다. 함정은 코드가 아니라, 봐서는 안 될 누군가였다.
요약
AI 모니터링 플랫폼의 지표가 인위적으로 조작되고 있음을 발견한 Alex가 이를 추적하는 과정을 다룹니다. 데이터 드리프트가 아닌 정교한 조작을 확인하기 위해 섀도우 훈련 파이프라인을 구축하여 오염원을 찾아내는 기술적 추적을 묘사합니다.
핵심 포인트
- 정상적인 모델 드리프트와 인위적 지표 조작의 차이점 분석
- 이상 탐지율, 오탐율, 커버리지율 곡선의 패턴 분석을 통한 위협 감지
- 데이터 오염 추적을 위한 섀도우 훈련 파이프라인 구축 기법
- CI/CD 파이프라인 내 데이터 서명(signature)을 활용한 추적 전략
벽돌을 던져 옥을 얻는다.
— 삼십육계 (The 36 Stratagems), 抛砖引玉 (Throw Out a Brick to Get a Jade)
이 시리즈의 이전 이야기:
#8: Alex가 AI 대시보드가 장악하는 것을 지켜보았다. — MedTech는 백만 달러 규모의 AI 모니터링 계약을 체결했다. Alex는 훈련 과정 중에 숨겨진 패널을 구축했다. 패널이 실패했을 때, 오직 그만이 프로덕션 (production) 데이터를 읽고 쓸 수 있었다.
#15: Derek와 Alex는 서버 하나를 공유했다. ACL의 AI가 둘 모두를 도청하고 있었다. — Alex와 Derek는 ACL의 감사 엔지니어 (audit engineer)를 돌리기 위해 가짜 경고를 만들었고, 그 후 수집기 (collector) 출력을 재지정했다. Alex는 ACL의 파이프라인 (pipeline) 시그니처를 결코 잊지 않았다.
Alex는 경고 (alert)를 지켜보지 않았다. 그는 패턴 (pattern)을 지켜보았다.
경고는 무엇이 고장 났는지를 알려준다. 패턴은 무엇이 변하고 있는지를 알려준다.
월요일 아침, 새벽 2시. 그는 지난 16주 동안의 MedTech AI 모니터링 플랫폼 민감도 곡선 (sensitivity curves)을 추출했다. 세 개의 선: 이상 탐지율 (anomaly detection rate), 오탐율 (false positive rate), 커버리지율 (coverage rate). 처음 두 개는 정상 범위 내에서 변동했다. 세 번째 — 멸균 준수 이상 탐지 (sterilization compliance anomaly detection) — 는 매주 1.5%씩 하락했다.
곡선이 너무 매끄러웠다. 무작위 노이즈 (random noise)가 아니었다.
그는 모델 드리프트 (model drift), 데이터 시프트 (data shift), 설정 변경 (config changes)을 확인했다. 만약 자연스러운 드리프트였다면 곡선은 선형적이지 않았을 것이다. 드리프트에는 가속 단계와 정체 단계가 있기 때문이다. 매주 정확히 1.5%씩 떨어지지는 않는다. 누군가 정해진 일정에 따라 지표 (metric)를 누르고 있었다.
그는 오염원 (contamination source)을 찾을 수 없었다. 하지만 오염된 데이터가 어디로 가는지 추적할 수는 있었다.
미끼 (The Bait)
Alex는 CI/CD 파이프라인 (pipeline) 내부에 섀도우 훈련 파이프라인 (shadow training pipeline)을 구축하는 데 4일을 보냈다.
그것은 MedTech의 실제 훈련 파이프라인 (training pipeline)과 동일했다. 동일한 API 엔드포인트 (API endpoints), 데이터 형식 (data format), 인증 프로토콜 (auth protocol), 로그 형식 (log format). 구분이 불가능했다.
한 가지 차이점은 이 파이프라인을 통해 흐르는 데이터에 보이지 않는 서명 (signature)이 부여된다는 점이었다. 샘플 ID와 파이프라인 키 (pipeline key)로부터 생성되어, 각 훈련 샘플의 부동 소수점 특징 값 (float feature values) 중 최하위 비트 (LSB, least significant bit)에 삽입되는 128비트 해시 (hash)였다. Float32 가수부 (mantissas)는 23비트를 가진다. LSB를 수정하면 $10^{-7}$ 스케일의 오차가 발생하지만, 모델 훈련 정확도에는 무시할 수 있는 수준의 영향만을 미친다.
그는 CI/CD 라우팅 설정 (routing config)의 train-pipeline-fallback 항목 아래에 섀도우 파이프라인 (shadow pipeline) 주소를 등록했고, 가중치는 **12%**로 설정했다. 그 옆에는 다음과 같은 주석을 달았다.
# fallback pipeline active — 12% load distribution per Q3 infra scaling plan
완전히 정상적으로 보였다. 그레이스케일 (gray-scale) 트래픽 분할이었다.
만약 누군가 CI/CD 설정을 검토한다면, 일상적인 폴백 (fallback) 라우팅으로 읽을 것이다. Alex는 단지 그 12%의 끝에서 흐르는 데이터가 있어서는 안 될 곳으로 흘러가는지 지켜보고 있을 뿐이었다.
점심 식사 후, Mike가 Alex의 책상 옆을 지나가다 2초 동안 멈춰 섰다. 그는 오늘 아침 Q3 인프라 확장 계획 (infrastructure scaling plan)을 검토하고 있었고, 폴백 경로 (fallback route)가 변경 기록 (change record)에 바로 적혀 있었다. 그리고 Alex의 화면에는 마침 그 경로의 출력을 읽고 있는 검증 스크립트 (validation script)가 실행 중이었다.
"그 폴백 말이야."
Alex의 손가락이 키보드 위에서 멈췄다.
Mike는 대답을 기다리지 않고 계속 걸어갔다.
미끼를 물다
7일째 되는 날. 오전 2시 14분. 워터마크 탐지 스크립트 (watermark detection script)가 작동했다.
태그가 지정된 세 개의 훈련 레코드 (training records)가 외부 S3 버킷 (S3 bucket)에 나타났다:
s3://acl-data-lake-apse1/inbound/medtech/training/
Alex는 데이터 흐름을 역추적했다. 섀도우 파이프라인으로부터의 경로는 깨끗했다: CI/CD 러너 (runner) → data-validate-svc라고 불리는 마이크로서비스 (microservice) → 외부 S3.
그는 해당 서비스의 배포 이력 (deployment history)을 확인했다. data-validate-svc는 MedTech의 K8s 클러스터 (K8s cluster)에서 5개월 동안 실행되고 있었다. 금요일 밤에 배포되었으며, 더 이상 그곳에서 근무하지 않는 한 SRE의 계정으로 배포되었다. 권한은 한 번도 회수되지 않았다.
그는 서비스의 로그 (logs)를 추적했다. 사람이 운영하고 있는 것이 아니었다.
새벽 3시와 4시 사이, 서비스는 다음과 같은 작업들을 완료했다: 섀도 파이프라인 (shadow pipeline) 출력 수신 → 데이터 형식 (data format) 검증 → 외부 S3로 덤프 (dump) → 로컬 캐시 (local cache) 정리. 공백도, 변동도, 수동 개입의 흔적도 없었다.
원격으로 누군가 조작하는 것이 아니었다. 5개월 전 배포되어 조용히 실행되고 있는, 관리되지 않는 추출 파이프라인 (extraction pipeline)이었다.
Alex는 화면에 표시된 배포 날짜를 응시했다. 5개월. 거의 150일 동안의 학습 데이터 (training data)가 모두 이 파이프라인을 거쳐 갔다.
Alex는 즉시 움직이지 않았다. 그는 파이프라인의 동기화 주기 (sync cycle)를 확인하기 위해 3일을 기다렸다 — 72시간마다, 예정대로, 단 한 번의 누락도 없이.
그 후 그는 Mike의 사무실로 걸어가 문을 닫았다.
그는 노트를 펼쳤다. 페이지 모서리에는 두 숫자가 적혀 있었다: **1.5%**와 오전 2:14.
"학습 파이프라인 (training pipeline)에 **백도어 (backdoor)**가 있습니다. 멸균 준수 데이터 (sterilization compliance data)가 5개월 동안 매주 1.5%씩 유출되었습니다. 출처는 ACL입니다."
네 문장이었다. 결론은 없었다.
Mike는 몸을 뒤로 기댔다.
"계속해봐."
그날 저녁, 복도에서. Alex와 Mike가 서로 스쳐 지나갔다. Mike가 말했다:
"그 탈출구 (exit)는 아직 활성화되어 있나?"
"그렇습니다."
Mike는 고개를 끄덕였다. 멈추지 않고 걸어갔다.
노이즈 (The Noise)
Alex는 추출 파이프라인을 중단시키지 않았다.
대신 그는 섀도 파이프라인 (shadow pipeline)에 새로운 로직 레이어 (logic layer)를 추가했다: 워터마크 (watermarked)가 찍힌 데이터를 가짜 암호화 셸 (encryption shell)로 감싸고, 이를 은퇴한 프로젝트의 MedTech 내부 아카이브 형식으로 위장하는 것이었다. 추출 파이프라인은 이전과 마찬가지로 이를 ACL의 S3 버킷 (S3 bucket)으로 동기화하겠지만, ACL은 해석 불가능한 아카이브 노이즈 (archive noise) 외에는 아무것도 읽을 수 없을 것이다.
그는 이것이 양날의 검이라는 것을 알고 있었다. 만약 상대측에서 데이터 품질을 모니터링하고 있다면, 갑작스러운 노이즈로의 전환 자체가 정보 유출의 신호가 될 수 있기 때문이다. 하지만 그는 5개월 동안 오류 없이 실행되어 온 파이프라인의 출력이 실시간으로 검토되지는 않을 것이라는 데 도박을 걸었다.
그는 노트에 세 번째 숫자를 적었다: data-validate-svc.
도구는 여전히 당신의 것이지만, 경로는 이제 나의 것이다.
열쇠 (The Key)
추출 파이프라인 (extraction pipeline)의 전체 데이터 흐름을 추적하던 중, Alex는 한 가지 세부 사항을 발견했다. 데이터가 어떤 아키텍처 다이어그램에도 나와 있지 않은 중간 서비스인 data-timeline-aligner를 통과하고 있었다. 평범한 이름이었다. K8s 레이블은 team: infra-optimization이었고, 3개월 전에 배포되었다.
그는 이 서비스의 입력과 출력을 살펴보았다. 타임스탬프 정렬 (timestamp alignment)을 수행하고 있었지만, 정렬되는 타임스탬프 형식이 그 어떤 MedTech 데이터 소스에도 속하지 않는 것이었다.
그는 소스 코드를 읽었다.
짧았다. S3에서 읽고, 타임스탬프를 변환하고, 다시 S3에 쓰는 표준적인 Python ETL 스크립트였다. 숨겨진 네트워크 호출도, 의심스러운 외부 의존성 (external dependencies)도 없었다.
하지만 세 번째 읽을 때, 그는 속도를 늦췄다.
코드가 무엇을 하는지 때문이 아니라, 코드가 어떻게 작성되었는지 때문이었다.
모든 줄이 순수 함수 (pure function)였다. 클래스(class)도, 데코레이터(decorator)도 없었다. 변수 이름은 모호하지 않을 정도로 충분히 짧았다: ts_src, dst_fmt, buf_size — 정보를 담고 있지 않은 모든 것은 잘려 나가 있었다.
그는 이를 ACL 파이프라인의 다른 서비스들과 비교했다. ACL의 코드는 이와 같지 않았다. 모든 줄에 팀 태그와 컴플라이언스 (compliance) 주석이 달려 있었다. 깔끔하고, 추적 가능하며, 개성이 없었다. 이 서비스는 달랐다.
그는 CPU 곡선을 다시 확인했다. 이 서비스의 데이터 처리량 (data throughput)은 주장된 타임스탬프 정렬 작업량과 일치하지 않았다. 단순한 정렬 작업에 필요한 것보다 3배나 많은 데이터를 처리하고 있었다. 단순히 타임스탬프를 정렬하는 것이 아니었다. 다른 무언가를 하고 있었다.
그는 잠시 멈췄다. 그는 단지 이 서비스 하나만 살펴보지 않았다. 그는 로컬 스크립트를 작성했다 — 추출 파이프라인이 존재하는 K8s 네임스페이스 (namespace)에서 Python 컨테이너를 사용하는 모든 포드 (Pod)를 가져와, 각 소스 코드의 핑거프린트 (fingerprint)를 추출했다.
출력 결과는 다음과 같이 표로 정리되었다:
| 서비스 | 배포 시기 | 코드 스타일 |
|---|---|---|
| ACL 표준 서비스 (67개) | 분산됨 | 클래스, pylint, docstrings |
| ... |
네 개의 서비스. 동일한 손길이었다. 파이프라인, 스토리지, 모니터링 — 세 개의 서로 다른 계층에 걸쳐 퍼져 있었다. 각각은 정상적으로 보이지만 존재해서는 안 될 무언가를 수행하고 있었다. 이들은 모여서 ACL 자신조차 알지 못하는 관측망 (observation net)을 형성하고 있었다.
가장 오래된 두 개는 ACL의 자체 추출 파이프라인 (extraction pipeline)이 배포된 것과 같은 주에 배포되었다. 우연이 아니었다. 이 존재가 누구든 간에, ACL이 진입점 (entry point)을 여는 순간 이를 찾아냈고, 그와 병행하여 자신들만의 접점 (contacts)을 배치해 둔 것이었다.
각 서비스의 CPU 및 메모리 점유율 (footprint)은 50m CPU 및 128Mi 미만이었다. 인프라 구성 요소처럼 이름이 지어져 있었고, team: infra-optimization이라는 라벨이 붙어 있었다. 이들은 비용 최적화 스캔 (cost optimization scans)에 걸리지 않을 것이고, 사고 발생 시 첫 번째 용의자가 되지도 않을 것이며, 어떠한 온보딩 인수인계 (onboarding handover) 과정에서도 나타나지 않을 것이었다. 128Mi는 Python 인터프리터와 boto3를 실행하기 위한 기본값이었다. 만약 메모리 누수 (memory leak)가 발생하더라도, Pod는 한 달에 한 번 재시작될 것이고 K8s가 자동으로 다시 끌어올릴 것이었다.
이 클러스터에는 한 쌍의 눈만 있는 것이 아니었다. ACL보다 5개월 앞선 또 다른 눈이 있었다. 그리고 그는 이제야 그것을 발견했다.
그는 data-timeline-aligner의 소스 코드로 돌아갔다. 다시 읽었다.
디렉터리에 새로운 파일이 있었다: config/hmac_seed.yaml. K8s 마이크로서비스 (microservice)에서 흔한 일이었다. 서비스들은 경량 검증 (lightweight validation)을 위해 HMAC 시드 (seed)를 공유하곤 한다. 파일은 짧았다:
# TODO: 다음 스키마 마이그레이션 (schema migration) 이후 vault로 마이그레이션할 것
seed: "b3BlcmF0b3I6IHN0YWdlLTJjb21wYW55LnBpcGVsaW5lLmRlcGxveQpzZXJ2aWNlOiBkYXRhLXRpbWVsaW5lLWFsaWduZXIKLS0tLS1CRUdJTiBQR1AgUFVCTElDIEtFWSBCTE9DSy0tLS0tLS4uLg=="
설정 파일에 있는 Base64 토큰 — 어떤 코드베이스에서도 아무도 두 번 쳐다보지 않을 것이다. 그는 전체를 복사하여 디코딩했다.
ASCII 텍스트였다. **-----BEGIN PGP PUBLIC KEY BLOCK-----**로 시작했다.
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGeXz1kBEAC...
...
ACL의 자산이 아니었다. MedTech의 것도 아니었다.
Alex는 즉시 행동하지 않았다.
그는 몇 초 동안 그 Base64 문자열을 응시했다. 출처를 알 수 없는 PGP 공개 키 (public key) — 이것으로 메시지를 암호화하는 순간, 의도와 연결 경로, 그리고 회신 경로 모두가 해당 키 소유자의 것이 된다. 만약 키 자체가 함정이라면, 메시지를 보내는 것은 함정 속으로 걸어 들어가는 것과 같았다.
그는 그 키로 메시지를 암호화했다. 페이로드 (Payload)에는 자신의 공개 키 (Public key)와 회신 경로가 포함되어 있었다. 이는 ACL의 S3 버킷 (S3 bucket)에 있는 것과 동일한 키 접두사 (Key prefix)였다. 그런 다음 그는 암호문 (Ciphertext)을 data-timeline-aligner의 /healthz 엔드포인트 (Endpoint)로 POST 요청했다.
엔드포인트는 200을 반환했다. GET 요청은 상태 확인 (Health status)을 반환했다. 특정 헤더 (Header)를 포함한 POST 요청은 S3에 조용히 기록되었다 — 로그도, 흔적도 남지 않았다.
메시지는 짧았다:
찾았다. 멋진 주석들이군.
오전 4:17. 그는 해당 S3 경로를 확인했다.
새로운 암호화된 파일이 기다리고 있었다. 그는 그것을 가져와 오직 자신만이 복호화 (Decrypt)할 수 있는 키로 열었다.
세 글자였다:
채널 확인됨.
설명은 없었다. 코드와 마찬가지로 — 직설적이었다.
그는 답장을 쓰지 않았다. 그는 터미널 (Terminal)을 열고, 그 공개 키로 짧은 데이터 패킷을 암호화했다 — 메시지가 아니라, 암호학적 핸드셰이크 (Cryptographic handshake) 확인이었다. 그것을 추출 파이프라인 (Extraction pipeline)의 동기화 경로 (Sync path)와 인접한 ACL의 S3 버킷에 기록했다. 오직 개인 키 (Private key) 소유자만이 완료할 수 있는 양방향 검증 (Bidirectional verification)이었다.
그러고 나서 그는 노트북을 덮고, 마지막 페이지로 넘겨 빈 페이지를 남겨두었다.
상대방이 키를 남긴 방식은 그가 빈 페이지를 남긴 방식과 같았다 — 지금 당장은 아니더라도, 언젠가 어느 날을 위해서.
Alex는 노트북을 닫았다. Third Cup의 불은 여전히 켜져 있었다.
카운터 뒤의 사람은 밤의 마지막 잔을 닦고 있었다. 테이블은 하나 남았다 — 창가 쪽 테이블이었다.
테이블 위에는 닫힌 노트북이 있었다. 그 옆에는 화면이 아래로 향한 채 놓인 휴대폰이 있었다. 누군가 의자에 몸을 기대고 앉아, 떠나지 않고 있었다.
테이블 위의 휴대폰이 밝아졌다.
알림이 아니었다. SSH 터널 (SSH tunnel) 체크 스크립트의 자동 에코 (Auto-echo)였다 — 세 줄의 내용: S3 버킷 경로 도달 가능, 두 개의 새로운 객체 (Objects), 그중 하나는 **ACK**를 읽음.
P는 즉시 휴대폰을 집어 들지 않았다. 화면이 어두워지게 두었다. 그러고 나서 손을 뻗어 휴대폰을 뒤집고, 한 손으로 명령어를 입력했다.
복호화 (Decrypt). 읽기. 눈에 띄는 반응은 없었다.
일을 마친 P는 휴대폰을 다시 테이블 위에 내려놓고, 카운터로 걸어갔다.
카운터 뒤에 있던 사람은 닦고 있던 잔을 내려놓고, 커피를 만들기 위해 몸을 돌렸다. 말 한마디 없이 커피를 밀어주었다. 이 시간에 이 사람을 본 것이 처음은 아니었다.
P는 커피를 집어 들었다. 블랙커피를 마셨다. 빈 잔을 다시 카운터 위에 올려두었다. 창가로 다시 걸어갔다. 휴대폰을 주머니에 넣고 재킷 안주머니에 찔러 넣었다. 뒤돌아보지 않고 문을 밀어 열었다.
카운터 뒤에 있던 사람은 문이 닫히는 것을 바라보았다. 셔터를 내렸다.
벽돌이 던져졌다. 옥(Jade)이 드러날 것이다.
그 또한 누군가의 옥이었다.
그것이 바로 성동격서 (Throw Out a Brick to Get a Jade)이다 — 가짜로 누군가를 속이는 것이 아니다. 오직 적절한 사람만이 알아챌 수 있는 표식을 남긴 채 진짜를 내놓는 것이다. 그러고 나서 상대방이 단순히 당신을 지켜보고 있다는 사실뿐만 아니라, 당신보다 먼저 누군가가 이곳에 있었다는 사실을 알려줄 때까지 기다리는 것이다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기