Skip to content
isdnetworks
Go back

브라우저 코드에 박힌 Slack 토큰은 바꿔 끼우지 않고 경유지를 세워 없애기로 했다

넘겨받은 백오피스는 PHP 와 JavaScript 로 된 오래된 화면 묶음이다. 화면 곳곳에서 Slack 으로 알림을 보내는데, 그 Slack 토큰이 브라우저로 내려가는 코드 안에 들어 있었다. 토큰을 revoke 하기로 한 것은 정해져 있었고, 남은 질문은 언제·어떤 순서로 하느냐였다. 그 순서를 정하던 전제가 실측 한 번으로 뒤집혔다.

Table of contents

Open Table of contents

증상 — 아무 주소에나 실려 나가는 토큰

이 백오피스 서버는 존재하지 않는 모든 경로에 사이트 루트 페이지(17,807바이트)를 HTTP 200 으로 돌려준다. 그 페이지 안에 Slack 토큰이 있다.

GET /                       200  17,807
GET /zzz-does-not-exist-1   200  17,807
GET /a/b/c/nope.php         200  17,807
GET /.env                   200  17,807

인증 없이, 어떤 주소를 쳐도 같은 페이지와 같은 토큰이 나온다. 로그인 화면도 로그인 전에 다른 토큰을 내려보냈다. 서버 파일을 읽어서 추정한 것이 아니라 공개 응답 본문에서 토큰 지문을 직접 뽑아 확인했다.

측정 — 126개 파일과 90만 번의 노출

토큰이 든 파일 126개를 전부 공개 URL 로 익명 요청했다. 121개가 응답에 토큰을 담아 돌려줬고(.js 75 · .php 46), 응답은 125건이 200, 1건이 302 였다. 차단하는 것이 없었다. 토큰을 내주지 않은 5개는 모두 서버 쪽에서만 쓰는 PHP 였다.

웹 서버 로그로 노출 규모도 쟀다.

기간캐치올 페이지 전달
최근 30일155,964건(고유 IP 56)
보관 로그 전체905,779건
30일 중 명백한 스캐너 탐침(.env·wp-·.git·phpinfo 등)30,482건

루트 index.php 의 수정 시각으로 보면 최소 1년 동안 이 상태였다. .env 를 긁는 봇에게 90만 번 페이지를 내준 셈이다. 그 봇들이 토큰 문자열까지 파싱했는지는 알 수 없지만, 알 수 없다는 것이 안전하다는 뜻은 아니다. 이 토큰은 손상된 것으로 간주한다.

원인 — revoke 를 미루게 만든 잘못된 전제

처음 합의된 순서는 「대체 경로를 먼저 만들고 revoke 는 마지막」이었다. 근거는 두 가지였다. 하나는 노출이 잠재적이라는 가정이고, 다른 하나는 revoke 하면 로그인까지 막힌다는 인식이었다. 로그인 화면의 2차 인증 코드가 Slack 으로 발송된다고 알려져 있었기 때문이다.

로그인 코드를 직접 읽어 보니 뒤쪽 인식이 틀렸다. 인증 코드는 브라우저가 Math.random() 으로 만들어 alert() 로 입력한 사람 화면에 띄우고, 검증도 브라우저에서 끝난다. Slack 게시는 그 뒤에 남는 감사 기록일 뿐이다. revoke 해도 로그인과 가격 변경은 막히지 않고, 끊기는 것은 Slack 알림과 감사 흔적이다. 이 구분이 없으면 revoke 는 「로그인이 막히니 미뤄야 하는 일」로 계속 남는다.

같은 코드에서 더 큰 문제 두 가지도 나왔다. 2차 비밀번호는 검증이 클라이언트에 있어 개발자 도구로 건너뛸 수 있으므로, 실질적인 인증은 Firebase Auth 의 이메일과 비밀번호 하나뿐이다. 그리고 로그인이 실패하면 시도한 비밀번호를 평문 그대로 외부 데이터베이스에 무인증 PUT 으로 쓴다. 오타 한 번이면 정상 비밀번호가 기록된다.

판단 — 다시 정한 순서와 토큰을 없애는 설계

전제가 바뀌었으니 잃는 것을 다시 쟀다. revoke 를 미루면 이미 공개된 토큰이 유효한 채로 남고, 하루 5천여 번씩 노출이 더해진다. 반면 revoke 를 당기면 Slack 알림과 감사 흔적이 끊기지만 서비스·로그인·가격 변경은 멈추지 않는다. 대체 완료를 기다릴지, 먼저 끊고 알림 공백을 감수할지는 결정권자가 판단할 일이다. 다만 그 판단의 전제는 이 수치로 바꿔 다시 올려야 한다. 대체와 무관하게 지금 바로 할 수 있는 것도 있다. 루트 index.php 의 Slack 호출 1지점을 지우면 도메인만 알면 닿는 가장 넓은 노출면이 닫힌다. 나머지 120개는 경로를 알아야 닿지만, 121/126 이라는 수치는 개별 차단으로는 따라갈 수 없다는 뜻이다.

대체 설계는 두 가지 중에서 골랐다.

  1. 권한 범위가 좁은 봇 토큰(xoxb)으로 바꿔 끼운다
  2. 알림 릴레이 하나를 세우고, 브라우저는 토큰 없이 릴레이만 부른다

2번을 고른 이유는 1번이 권한 범위만 좁힐 뿐, 토큰이 클라이언트 JavaScript 에 있는 한 똑같이 유출되기 때문이다. 토큰을 바꿔 끼우는 대신 경유지를 세운다. 원칙은 「클라이언트에서 자격증명을 없앤다」다.

브라우저 ──POST /notify {channel, text}──▶ 릴레이 ──chat.postMessage──▶ Slack
             (Firebase ID 토큰 첨부)          (봇 토큰은 서버에만)

릴레이는 호출자의 Firebase ID 토큰을 검증한다. 모든 화면이 이미 Firebase Auth 를 쓰므로 로그인을 더할 필요가 없다. 실제로 쓰는 채널만 화이트리스트로 허용하고, 위치는 Cloud Run 이나 Firebase Functions 처럼 GCP 쪽 플랫폼이 가진 자리에 둔다.

대가는 생각보다 컸다. 처음에는 공용 헬퍼 하나만 고치면 될 줄 알았지만, report_slack 함수를 정의한 파일이 98개였고 그 본체가 서로 다른 판으로 최소 65종이었다. 복사해 붙인 뒤 각자 바뀌었고, 토큰도 그 복사본마다 박혀 있다. 일괄 치환하면 변형된 판에서 어긋나므로 파일마다 정확히 한 번 일치하는지 센 뒤에 넣어야 하고, 기간도 98개 파일 수정으로 잡아야 한다. 그래서 전체 대체를 기다리지 않고 한 채널씩 먼저 우회하는 길도 함께 둔다.

결과 — 토큰 교체 대신 세운 경유지

정리


Share this post on:

Previous Post
결제를 주문 시점으로 당기며 빌링키는 버리지 않고 남겨, 결제 시점만 옮기는 쪽을 택했다
Next Post
AI 에이전트 세션을 기능이 아니라 배포 경계로 나눠 넘겨받은 시스템을 운영했다