배포와 인프라8 분 읽기

회수했던 DB 권한이, 어느 재생성에서 조용히 되돌아와 있었습니다

주간 보안 메일은 무해한 항목 하나만 알려줬습니다. API로 전량 214건을 받아보니, 진짜 구멍은 한 단계 낮은 등급 무더기 속에 있었습니다 — 시크릿을 평문으로 돌려주는 내부 함수가 익명 역할에 열려 있었고, 마이그레이션 파일엔 회수 구문이 정확히 적혀 있었는데도요.

#database#security#gotchas#reality-check
왼쪽은 마이그레이션 파일 아이콘과 잠긴 자물쇠(REVOKE 적힘), 오른쪽은 라이브 DB 아이콘과 열린 자물쇠, 둘 사이 화살표에 '재생성' 라벨. 아래엔 필터 깔때기와 그 옆으로 빠져나가는 문서 아이콘 하나에 'SELECT only' 라벨
마이그레이션에 회수 구문을 넣었다는 건, 현재 상태의 증거가 아닙니다.

당신이 마이그레이션 파일에 적어 둔 REVOKE는, 지금 라이브 권한 목록에 그대로 반영돼 있습니까?

주간 보안 경고 메일이 왔습니다. "테이블이 공개돼 있다"는 정형 메일이고, 지목한 항목은 확장 프로그램이 만든 좌표계 테이블 하나였습니다. 우리 권한으로 고칠 수도 없고 내용도 공개 표준 데이터라 위험이 없는 항목입니다. 세 번째로 같은 메일을 받은 참이라 "또 그거군" 하고 넘길 수 있었습니다.

그래서 메일 대신 API로 전량을 받아 봤습니다. 214건이었고, 그중 심각도 최고 등급은 정말로 그 한 건뿐이었습니다.

당신이라면?

최고 등급이 무해한 한 건뿐입니다. 여기서 닫으시겠습니까, 아니면 한 단계 낮은 경고 193건을 열어 보시겠습니까? 진짜 구멍은 그 무더기 속에 있었습니다.

진짜 구멍은 등급이 한 단계 낮은 무더기 속에 있었습니다

시크릿 저장소를 읽어 주는 내부용 래퍼 함수 두 개가 익명 역할에 실행 권한이 붙은 채 열려 있었습니다. 이름만 넘기면 평문을 그대로 돌려주고, 신원 검사가 없습니다. 앱에 박혀 배포되는 공개 키만 있으면 REST로 호출됩니다. 실제로 찔러 보니 200이 왔습니다.

반전이 둘입니다.

하나. 이 함수를 만든 마이그레이션은 제대로 잠그고 있었습니다. 파일에는 REVOKE ALL ... FROM PUBLIC과 서비스 역할에만 주는 GRANT가 정확히 적혀 있었습니다. 그런데 라이브 권한 목록에는 익명 역할과 인증 역할이 다시 들어와 있었습니다. 플랫폼의 기본 부여가 새 함수에 그 두 역할을 자동으로 붙이기 때문에, 어느 시점의 재생성이 잠금을 되돌린 겁니다. "마이그레이션에 회수 구문을 넣었다"는 현재 상태의 증거가 아닙니다.

둘. 두 달 전에 같은 종류를 전수 점검했는데도 이건 못 봤습니다. 그때 판별을 3단계로 좁혔고, 2단계가 "본문에 insert·update·delete가 있는 것"이었습니다. 시크릿을 돌려주는 함수는 한 줄도 쓰지 않습니다. 필터에서 구조적으로 사라진 겁니다.

쓰기 여부로 좁히면 최고 심각도를 놓칩니다.

정직하게

  • 노출 기간에 실제로 꺼내 갔는지는 증명할 수 없습니다. 접근 로그로 사후 확인이 안 됩니다.
  • 그래서 회수만으로는 끝이 아니고, 배포·서버 자격증명 회전이 남습니다. 회전은 다른 시스템을 동시에 멈추는 작업이라 별도 계획이 필요합니다.
  • 등급으로 우선순위를 정하면 안 된다는 걸 세 번째로 확인했는데, 세 번 다 같은 메일에 당했습니다. 메일을 무시하는 습관 자체가 위험 신호였습니다.

읽기 갈래를 따로 돌리는 게 이번 교훈입니다

전량 조회 → 등급별 분포: 최고 1 / 경고 193 / 정보 20. 경고 안에 익명 실행 가능한 정의자 함수 83건.

-- 쓰기 갈래로만 좁히면 시크릿 반환 함수가 사라진다
and p.prorettype <> 'trigger'::regtype
and p.prosrc ~* 'select'
and not (p.prosrc ~* 'auth\.uid|auth\.jwt|auth\.role')

회수 절차에서 실제로 도움이 된 두 가지:

  1. 자기검증 블록을 단독으로 먼저 돌려 반드시 실패하는지 봅니다. 이걸 안 하면 검사가 비어 있는 채로 통과한 것을 성공으로 읽습니다.
  2. 회수 뒤 익명 키로 다시 찔러 permission denied를 받아야 증거입니다. 인자를 여분으로 실어 보내면 시그니처 불일치 404가 뜨는데, 이걸 성공으로 착각하기 쉽습니다. 실제 시그니처로 보내야 합니다.

읽기 갈래로 다시 훑은 13건 중 나머지는 전부 설계상 공개였습니다. 정적 규칙만 보고 라이브 앱의 공개 API를 회수하면 앱이 깨집니다 — 신원 검사 부재가 곧 구멍은 아닙니다.

자가진단 3개

  • 마이그레이션 파일에 REVOKE를 적어 뒀다면, 그게 라이브 권한 목록에 지금 반영돼 있는지 조회해 본 적 있습니까? 함수 재생성은 기본 부여를 다시 붙입니다.
  • 정의자 권한 함수를 감사할 때, 필터가 "본문에 쓰기가 있는 것"으로 좁혀져 있습니까? 시크릿을 돌려주는 함수는 한 줄도 쓰지 않아 그 필터에서 사라집니다.
  • 회수 뒤 "막혔다"는 판정을, 실제 시그니처로 호출한 permission denied로 받았습니까, 아니면 인자 불일치 404를 성공으로 읽었습니까?

이 글은 게시판 삭제 마스터키가 앱 안에 박힌 공개 상수였던 이야기의 후속입니다. 앞글은 함수 본문에 박힌 상수가 곧 권한이었다는 이야기, 이 글은 함수 권한 자체가 조용히 되돌아왔다는 이야기입니다.

지금 익명·공개 키로 실행 가능한 정의자 함수 목록을 뽑고, 그중 값을 돌려주는 함수가 몇 개인지 세어 보세요. 쓰기로 좁힌 감사에서는 안 나옵니다.

관련 글