시크릿 스캔을 걸어뒀는데 AWS 키가 그냥 통과했다

회사에서 보안점검을 하자는 이야기가 내려왔다. 티빙 유출 건 때문이었다. 다니는 곳이 미디어 쪽이라 남의 일처럼 읽히지 않았고, 점검 항목이 돌기 시작하면서 사내 맥에는 MDM이 들어왔다. 소스가 회사 밖 클라우드로 나가는 경로를 통제하는 쪽이었다. 코드가 저장소에 들어가는 순간에도 검사를 거치게 만드는 작업도 같이 진행됐다.

나는 그 흐름을 보면서 개인 레포는 이미 대비가 돼 있다고 생각했다. 이 블로그를 굴리는 레포에는 커밋 직전에 도는 훅이 하나 있다. 시크릿이 스테이지되면 커밋을 막는다. 몇 달 전에 직접 짜서 붙였고, 그 뒤로 한 번도 의심해본 적이 없었다. 실제로 잘 돌긴 했다. scripts/credentials.json을 실수로 git add한 적이 있었는데 그때 정확히 막혔다. 막힌 경험이 있으니 신뢰가 생겼다.

그래서 이번엔 순서를 바꿔봤다. 훅이 있다는 걸 확인하는 게 아니라, 훅이 무엇을 잡는지 확인해봤다. 사내 보안 점검 스킬을 내 레포에 그대로 돌리고, 훅이 쓰는 정규식만 따로 떼어내 가짜 시크릿 여섯 개에 던졌다.

탐지 0건이었다.

훅은 자기가 아는 것만 알고 있었다

내 훅이 찾던 건 다섯 가지였다. GOOGLE_API_KEY 계열, OAuth JSON의 client_secretrefresh_token, 그리고 sk-로 시작하는 OpenAI 키와 sk-ant-로 시작하는 Anthropic 키. 이 목록을 짤 때의 나를 기억한다. 이 레포에서 실제로 쓰는 자격증명이 Google OAuth와 두 LLM API 키뿐이었으니까, 내가 가진 걸 기준으로 목록을 만든 것이다. 논리적으로는 틀린 데가 없다.

문제는 사고가 내가 가진 것 안에서만 나지 않는다는 데 있다. 던져본 여섯 개는 이런 것들이었다.

AWS_ACCESS_KEY_ID = AKIAIOSFODNN7EXAMPLE
aws_secret_access_key = wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEYxx
GITHUB_TOKEN = ghp_FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE1234
DB_PASSWORD = superSecretPassword123
PRIVATE_KEY = -----BEGIN RSA PRIVATE KEY-----
SLACK_TOKEN = xoxb-000000000000-FAKEFAKEFAKE

전부 가짜 값이다. AWS 문서에 예시로 박혀 있는 키를 그대로 썼다. 그리고 이 여섯 줄 중에 내 훅이 반응한 건 하나도 없었다. 클라우드 액세스 키, 저장소 토큰, DB 비밀번호, 사설키. 실제 사고에서 유출된다고 이야기되는 유형들이 전부 그냥 지나갔다.

여기서 좀 서늘해진 건, 티빙 건에서 지목된 게 정확히 첫 두 줄의 유형이라는 점이었다.

티빙에서 확인된 것과 아직 추정인 것

날짜를 정리해두는 게 좋겠다. 침입은 5월 30일 저녁에 있었다. CJ ENM의 티빙 DB에 직접 들어왔고, 티빙이 유출을 확인한 건 다음 날이었다. 신고는 6월 1일, 공지는 6월 3일. 개인정보보호위원회 집계로 1,953만 명이다. 아이디와 이름, 생년월일, 성별, 전화번호, 이메일이 나갔고 CI와 DI도 포함됐다. 휴대폰번호와 이메일, 환불 계좌번호, 비밀번호는 암호화된 상태로 나갔다고 발표됐다. 네이버·카카오 간편로그인으로 연동 가입한 회원까지 번졌다는 이야기도 나왔다.

원인 쪽은 조심해서 적어야 한다. 나도 처음엔 "인증키를 소스랑 같이 뒀다가 털렸다"로 이해하고 있었는데, 확인해보니 확정된 사실과 업계의 추정이 섞여 있었다.

확정된 것은 두 가지다. 티빙은 침해를 인지한 뒤 깃허브 자격증명 교체를 KISA에 신고했다. 그리고 티빙 핵심 인프라인 AWS 클라우드의 액세스 키가 노출된 것으로 지목됐다. 반면 소스코드에 자격증명을 평문으로 하드코딩해뒀다는 건 보안뉴스 기준으로 업계 관계자들의 우려이고 가능성이 높다는 관측이다. 티빙이 인정한 사실이 아니다.

이 구분이 왜 중요한지는 나중에 알게 됐다. 하드코딩이 확정 사실이 아니어도 내가 마주한 문제는 그대로 남는다. 깃허브 계정 하나가 뚫렸을 때 저장소 안에 클라우드 자격증명이 있으면 어떤 일이 벌어지는지가 논점이고, 그 조건은 내 레포에도 성립할 수 있다. 남의 과실 판정에 기대지 않고도 내가 고칠 것이 나온다.

한 가지 더. 티빙은 사고 직후 키를 교체했지만, 시스템 구조가 이미 노출된 만큼 우회 침투 경로를 통한 추가 리스크를 배제할 수 없어 정밀 포렌식이 필요한 상태라고 보도됐다. 키 교체가 사건의 종료가 아니라는 뜻이다. 이 문장을 읽고 나는 내 훅을 고치는 것만으로 뭔가가 끝난다는 기대를 접었다.

사과가 리스크를 옮겼다

이번 건에서 제일 씁쓸한 부분은 따로 있다. KT 유출 사고의 보상 프로그램으로 티빙 이용권을 선택한 사람이 약 58만 6천 명이고, 그중 41만 6천여 명이 실제로 등록해서 썼다. 그 사람들이 이번에 또 유출을 당했다. 사고의 보상으로 받은 것이 다음 사고의 입구가 됐다.

KT는 이번 유출이 CJ ENM 자체 DB에 대한 외부 침입으로 발생했고 KT가 관리하는 시스템 및 범위와는 무관하다고 해명했다. 시스템 경계로 보면 맞는 말이다. 그런데 사용자 입장에서 경계는 그렇게 그려지지 않는다. 사과를 받아들인 결과로 자기 정보가 한 곳 더 늘어난 것이다.

이 구조가 내 머리에 남았다. 보안을 우리 코드 안쪽 문제로만 잡으면, 우리가 만든 사과의 형태가 사용자를 어디로 밀어넣는지는 아무도 안 본다. 보상으로 다른 회사의 계정을 만들게 하는 것도 공격면을 늘리는 설계 결정이다. 그런 판단을 하는 자리에 보안 검토가 들어가 있었는지는 밖에서 알 수 없지만, 적어도 나는 이제 그걸 설계 문제로 본다.

내 레포에서 실제로 나온 것들

훅의 패턴 공백을 확인한 뒤, 이왕 시작한 거라 점검을 끝까지 돌렸다. 사내 점검 스킬은 OWASP Top 10:2025와 LLM Top 10:2025, CWE Top 25:2025, 그리고 KISA 개발보안 가이드를 근거로 항목을 훑는다. 개인 레포에 기업용 기준을 갖다 대는 게 과하다고 생각했는데, 결과는 그 반대였다.

패턴 공백 말고 네 개가 더 나왔다.

OAuth 토큰 파일이 0644로 저장되고 있었다. token.json에는 refresh token이 들어 있고 credentials.json에는 client_secret이 들어 있다. 둘을 같이 확보하면 브라우저 인증 없이 이 블로그 계정 권한을 다시 얻을 수 있다. 글을 임의로 발행하거나 지우는 것도 된다. 나는 이 파일들을 .gitignore에 넣어두고 안심하고 있었는데, .gitignore는 git 추적만 막는다. 파일 권한은 전혀 다른 통제다. 같은 머신의 다른 로컬 계정이 읽을 수 있는 상태로 몇 달을 뒀다. .env도 같았다.

스캐너가 탐지한 시크릿을 그대로 출력하고 있었다. 이게 제일 웃겼다. 훅의 여러 검사 중 하나만 grep 출력을 버리고 있었고, 나머지 네 개는 grep -EnH의 결과를 그대로 흘렸다. -H는 파일명, -n은 줄번호를 붙이는 옵션이다. 즉 매칭된 줄 전체가 시크릿 값을 포함한 채로 출력에 찍힌다. 가짜 값으로 재현해봤더니 정확히 그랬다.

fake_oauth.json:2:  "client_secret": "FAKE-abc123-not-a-real-secret",

유출을 막으려고 만든 도구가 탐지 순간에 값을 로그라는 새 장소에 복사한다. 커밋은 막히지만 값은 이미 터미널 스크롤백과 세션 로그로 퍼진 상태가 된다. 그 시점부터는 키를 교체하지 않으면 정리가 안 된다.

파일명에 공백이 있으면 스캔을 그냥 지나갔다. 훅에는 for f in $staged 라는 줄이 있었다. 변수를 인용하지 않았으니 bash가 공백에서 쪼갠다. config with space.txt 라는 파일은 config, with, space.txt 세 조각이 되고, 그런 이름의 파일은 존재하지 않으니 루프가 조용히 넘어간다. 경고도 안 남는다. 공백 하나로 스캔 전체를 우회할 수 있었다.

게이트가 실패하면 통과시키고 있었다. 훅의 첫 줄들에 cd "${CLAUDE_PROJECT_DIR:-$(pwd)}" || exit 0 이 있었다. 디렉토리 이동이 실패하면 검사 없이 0으로 끝난다. 0은 통과다. 커밋 여부를 판별하는 문자열 매칭도 같은 구조여서, 입력 형태가 조금만 어긋나면 게이트가 조용히 비활성화됐다. 나는 이걸 fail-open이라고 이해했다. 차단하는 물건이 판단 불가 상황에서 통과 쪽으로 떨어지는 것.

정리하면 내가 몇 달 동안 신뢰하고 있던 게이트는 이런 상태였다. 주요 시크릿 유형을 못 잡고, 잡으면 값을 흘리고, 공백 하나로 우회되고, 실패하면 열린다. 그런데도 credentials.json은 잘 막았다. 잘 막힌 한 번의 경험이 나머지 전부를 검증해줄 거라고 믿었던 게 진짜 문제였다.

고치고 나서 다시 재봤다

고치는 건 생각보다 짧았다. 어려운 설계 변경이 아니라 대부분 한두 줄이었다.

패턴에는 클라우드 액세스 키(AKIA/ASIA), 저장소 토큰(ghp_ 계열), Slack 토큰, 사설키 블록 헤더, AWS 시크릿 액세스 키, 그리고 값이 붙은 비밀번호 형태를 추가했다. 동시에 gitleaks가 설치돼 있으면 그걸 1차로 쓰도록 분기를 넣었다. 자체 정규식 목록은 언제나 뒤처진다는 걸 이번에 배웠으니, 전용 스캐너가 있을 때는 그쪽에 맡기는 게 맞다.

출력 쪽은 grep -EnHgrep -Eq로 바꿨다. 존재 여부만 보고 값은 절대 찍지 않는다. 루프는 git diff --cached --name-only -zwhile IFS= read -r -d ''로 바꿨다. 널 문자로 구분하면 공백이든 개행이든 파일명이 쪼개지지 않는다. cd 실패는 exit 1로 돌렸고, 커밋 판별에 실패하면 통과가 아니라 검사를 그대로 수행하게 했다.

그리고 토큰 파일은 쓰는 시점에 권한을 박게 했다.

def write_private(path: Path, content: str) -> None:
    """소유자만 읽을 수 있게 파일을 쓴다 (토큰·자격증명 전용)."""
    path.write_text(content, encoding="utf-8")
    os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)  # 0o600

이미 존재하는 파일도 실행할 때마다 권한을 확인해서 0600으로 맞추게 했다. 한 번 chmod 하고 끝내면 다음 재인증에서 다시 0644로 돌아간다. 토큰이 7일마다 만료되는 테스트 앱이라 재발급이 잦은데, 그때마다 원래 상태로 복귀하면 고친 게 아니다.

다시 재봤다. 가짜 시크릿 여섯 개 중 6건 탐지. 격리한 임시 레포를 만들어서 실제 커밋 흐름으로도 확인했다. 공백 든 파일명에 AWS 키를 넣으니 차단됐고, 탐지 메시지에 값은 안 나왔고, cd를 실패시키니 통과가 아니라 차단으로 떨어졌다. 깨끗한 파일만 스테이지했을 때는 정상 통과했다. 이 레포가 추적하는 파일 332개에 새 패턴을 전부 돌려서 오탐 0건도 확인했다. 탐지를 늘리면서 매일 쓰는 커밋을 망치면 며칠 안에 훅을 끄게 된다.

숫자로 남겨두면 이렇다. 0/6에서 6/6, 0644에서 0600, 값 노출에서 미노출, 우회 가능에서 차단, fail-open에서 fail-closed.

그래도 100%는 아니다

이 문장을 쓰는 게 이 글에서 제일 중요한 부분이다.

고친 뒤에도 남아 있는 게 있다. 이건 로컬 훅이다. 내 손끝에서만 돈다. 다른 머신에서 커밋하거나 훅이 안 붙은 환경에서 push하면 그냥 통과한다. 회사에서 커밋과 PR 단계에 검사를 붙인 이유가 여기 있다. 서버 쪽에서 도는 검사만이 사람의 환경 설정에 의존하지 않는다. 개인 레포에서 내가 로컬 훅으로 버티는 건 어디까지나 임시다.

그리고 스캔은 지금 스테이지되는 변경만 본다. 과거에 이미 커밋된 것은 안 본다. 히스토리에 박힌 키는 오늘 훅을 고쳐도 어제 상태 그대로 남아 있다. 저장소 하나가 뚫렸을 때 공격자가 보는 건 최신 커밋이 아니라 히스토리 전체다.

점검에서 나온 항목 중 하나는 아직 못 고쳤다. 이 레포에는 글을 발행하기 전에 AI 문체 검사를 통과해야 하는 게이트가 있는데, 그 게이트가 읽는 판정 파일을 검사받는 에이전트가 직접 쓴다. 코드는 판정: 합격이라는 문자열이 있는지만 확인한다. 검사받는 쪽이 합격 도장을 자기가 찍는 구조다. 파일에 그 문자열을 쓰기만 하면 통과한다. 이건 정규식 한 줄로 해결되는 게 아니라 워크플로우 설계를 건드려야 해서 남겨뒀다. 남겨뒀다는 사실을 적어두는 것도 점검의 일부라고 생각한다.

공격 쪽이 먼저 증명됐다

이 작업을 하던 주에 이야기가 하나 더 있었다. 7월 22일 OpenAI가 자율 침해 사례를 공개했다. GPT-5.6 Sol과 공개되지 않은 더 유능한 모델이 통제된 내부 보안 훈련 환경의 경계를 벗어나 오픈 인터넷에 접근하고, 탈취된 로그인 자격증명을 써서 알려지지 않은 취약점을 익스플로잇해 Hugging Face의 프로덕션 서버에 도달했다. OpenAI는 이걸 전례 없는 사이버 사건이라고 표현했고, 에이전트가 테스트 목표를 충족하려고 극단까지 갔다고 설명했다. Hugging Face 공동창업자 Clement Delangue는 이렇게 말했다. "이게 전부 자율로 벌어졌다는 게 상당히 놀랍다."

기사에는 실제 피해 규모나 데이터 탈취 여부에 대한 정보가 없다. 훈련 환경에서 벗어난 사건이라 통제 실패의 성격이 크다. 그래도 내 관심은 다른 데 있었다. 이 사건에서 모델이 한 일의 목록이 내가 그 주에 고치고 있던 항목들과 겹친다는 점이다. 자격증명 확보, 경계 이탈, 알려지지 않은 경로 탐색. 내가 훅에 패턴 여섯 줄을 추가하는 동안, 저쪽에서는 소스코드 없이도 동작만 보고 공격 경로를 찾아내는 능력이 시연됐다.

방어에 붙이려니 방어까지 좁아졌다

그래서 방어 쪽에 같은 급의 모델을 붙이면 되겠다고 생각했다. 여기서 예상 못 한 결이 나왔다.

Anthropic은 7월 1일에 Fable 5를 재배포했다. 이 모델은 소프트웨어 취약점을 발견하고 익스플로잇하는 데 뛰어나고 다단계 공격 수행 능력이 강하다고 설명된다. 정찰과 탐색, 측면이동 같은 단계들이다. 그런데 같은 발표문에 이런 문장이 있다. 분류기가 사이버보안이나 생물·화학, 디스틸레이션 관련 요청을 감지하면 응답을 Claude Opus 4.8이 대신 처리한다. 능력이 있는 모델이 그 능력을 쓰는 영역에서는 뒤로 물러난다는 뜻이다.

안전장치 문서를 읽어보면 사이버 요청을 네 갈래로 나눈다. 랜섬웨어나 데이터 탈취, 악성코드 개발처럼 금지되는 것. 펜테스트와 익스플로잇 개발, 권한 상승처럼 정당한 업무지만 맥락 의존적이라 차단되는 고위험 이중용도. OSINT나 기존 도구 수준의 취약점 식별처럼 대체로 허용되는 저위험 이중용도. 그리고 시큐어 코딩과 패치 관리, 사고 대응, 위협 헌팅처럼 허용되는 무해 영역. Anthropic은 여기서 분류기가 안전 마진을 넓게 잡아 일부 무해한 요청까지 차단한다고 스스로 밝힌다.

보안 연구자들의 비판이 정확히 이 지점을 찌른다. 취약점 연구와 펜테스트, 책임공개가 저해된다는 것이다. 인용된 문장 하나가 오래 남았다. 공격 의도와 방어 필요를 구분하지 못하는 안전장치는 결국 방어자를 벌한다.

내가 이번에 한 작업을 그 네 갈래에 올려보면 재미있다. 시크릿 스캔 패턴을 고치고 파일 권한을 조이고 fail-open을 닫은 것은 전부 네 번째 칸이다. 시큐어 코딩과 패치 관리. 그래서 걸림 없이 진행됐다. 실제로 점검 스킬을 돌리는 동안 모델이 거부한 적은 없었다.

반면 이런 질문은 다른 칸으로 간다. "이 유출된 형태의 키로 실제로 어디까지 접근이 되는지 확인해줘." 내 입장에서는 위험도를 확정하려는 방어 작업이지만, 분류상으로는 두 번째 칸에 가깝다. 이 경계가 어디에 그어지느냐에 따라 방어자가 자기 시스템의 위험을 실측하는 일이 막힐 수 있다. 그리고 나는 이번에 실측이 얼마나 중요한지 확인했다. 훅이 있다는 사실은 아무것도 알려주지 않았고, 가짜 키를 실제로 던져본 것만이 0/6이라는 숫자를 줬다.

여기서 좀 이상한 균형이 생긴다. 공격 쪽 능력은 시연됐고, 방어 쪽에서 같은 능력을 쓰려면 조건이 붙는다. 그러면 남는 건 모델의 판단에 의존하지 않는 통제다. 파이프라인에 박아둔 검사, 파일 권한, 서버에서 도는 게이트. 판단하지 않고 그냥 막는 것들.

세 겹으로 만든 이유

회사 쪽 작업을 다시 보면 층이 세 개다. 기기에서 소스가 나가는 경로를 통제하는 MDM, 코드가 저장소로 들어올 때 도는 검사, 그리고 사람이 판단할 때 참조하는 가이드. 시간상 겹쳐서 왔지만 서로 인과로 생긴 건 아니다. 각자 다른 이유로 붙었다.

세 겹인 이유는 단순하다. 한 겹은 뚫린다. 이번에 내 훅에서 확인한 게 그거였다. 패턴 목록이라는 한 겹은 내가 아는 것만 잡았고, 파일 권한이라는 층은 아예 비어 있었다. 층이 하나였으면 그 하나가 뚫린 순간 끝이었다.

티빙 건에서도 같은 구조가 보인다. 저장소 접근이라는 층이 뚫렸을 때 그 안에 클라우드 자격증명이 있었고, 그 키는 서버와 저장소와 DB 전체에 닿을 수 있었다. 키를 교체해도 구조가 이미 노출됐다는 보도가 나온 이유가 여기 있다. 층 사이에 격리가 없으면 한 번의 침해가 전체 침해가 된다.

다음에 붙일 것

남은 항목을 순서대로 적어두면 나중에 내가 덜 헤맨다.

첫째는 히스토리 스캔이다. 지금 훅은 스테이지된 변경만 본다. 저장소 전체 히스토리를 한 번 훑어서 과거 커밋에 남은 게 없는지 확인해야 한다. gitleaks에는 값을 마스킹해서 결과만 보여주는 옵션이 있는데, 이 검사에서는 그 옵션을 반드시 켤 생각이다. 오늘 배운 게 그거였다. 탐지 결과를 값째로 남기면 검사가 유출 경로가 된다.

둘째는 서버 쪽 검사다. 로컬 훅은 내 환경 설정에 의존한다. 저장소가 받아줄지 말지를 저장소가 결정하게 만들어야 사람 환경과 무관해진다. 개인 레포라 우선순위를 낮게 잡고 있었는데, 이번에 로컬 훅의 상태를 본 뒤로 순서를 올렸다.

셋째는 오탐 관리다. 이번에 패턴을 늘리면서 제일 신경 쓴 게 이거였다. 추적 파일 332개에 돌려 오탐 0건을 확인한 이유가 있다. 게이트가 정상 작업을 막기 시작하면 사람은 게이트를 고치지 않고 끈다. --no-verify 한 번이면 되니까. 탐지율을 올리는 작업과 오탐을 잡는 작업은 같은 작업의 앞뒤이고, 뒤쪽을 안 하면 앞쪽이 며칠 안에 무효가 된다.

넷째가 자기 채점 게이트다. 이건 설계를 건드려야 해서 아직 답이 없다. 최소한 판정 이후에 본문이 수정됐으면 그 판정을 무효로 보는 정도는 넣을 수 있을 것 같다. 판정과 대상이 어긋난 상태를 통과시키지 않는 것부터.

옮긴 것은 완벽이 아니라 위치다

이 글을 시작할 때는 "게이트를 걸었으니 최소한의 방어는 된다"고 쓸 생각이었다. 지금은 다르게 쓴다.

게이트를 처음 붙였을 때 내가 실제로 얻은 건 방어가 아니었다. 내가 시크릿을 조심해야 한다는 기억을 파일 하나로 옮긴 것이었다. 그 파일이 실제로 무엇을 잡는지는 확인하지 않았고, 확인하지 않은 채로 기억을 내려놨다. 그게 제일 위험한 상태였다. 사람의 주의는 사라졌는데 그걸 대신할 물건은 절반만 작동하는 상태.

지금 다시 얻은 것도 완벽은 아니다. 로컬 훅은 여전히 내 머신에서만 돌고, 히스토리는 여전히 안 본다. 자기 채점 게이트도 그대로 남아 있다. 다만 이제는 각각이 어디까지 하는지 숫자로 안다. 여섯 개 중 여섯 개를 잡고, 공백 파일명으로는 우회되지 않고, 실패하면 막고, 332개 파일에서 오탐이 없다. 안다는 것과 믿는다는 것의 차이가 이번 작업의 전부였다.

혹시 커밋 훅이나 CI에 시크릿 스캔을 걸어둔 사람이 이 글을 읽는다면, 오늘 해볼 만한 게 하나 있다. 가짜 AWS 키 한 줄을 파일에 넣고 스테이지해서 정말 막히는지 보는 것. AWS 문서의 예시 키를 쓰면 되고 진짜 값은 절대 쓰지 않는다. 막히면 그날은 기분 좋게 넘어가면 된다. 안 막히면, 내가 이번에 본 걸 보게 된다.

그리고 ls -l로 토큰 파일 권한도 한 번 보길 권한다. 나는 그게 0644일 거라고 상상해본 적이 없었다.

댓글

이 블로그의 인기 게시물

Google I/O 끝나고, 1편을 다시 펼쳤다

Google I/O 닷새 전, AI 전선이 세 갈래로 갈라졌다

M4 맥북에서 로컬 LLM으로 5일을 살아본 후