2026년 11월 7일 토요일
미단재

구글봇 화이트리스트에 GCP 대역을 넣었다가 발생한 보안 이슈와 교훈

시리즈 운영서버에서만 터진 버그 현재 위치 12 / 12
  1. 운영 배포에서만 실패한 slug 마이그레이션: 무음 실패를 실패로 보이게 만들기
  2. 운영 서버에서만 발생한 TinyMCE 이미지 경로 버그 3선
  3. 호스팅이 419를 500으로 바꿔버린 날
  4. 목록에 —가 그대로 찍혔다: strip_tags 다음에 빠진 한 단계
  5. 페이지 번호가 41개 전부 나왔다: AI가 만든 페이저 뷰에서 빠진 한 줄
  6. TinyMCE 이미지 alt가 사라지는 원인과 해결책
  7. 새로고침하면 공간이 사라진다: 저장 버튼을 의심하기 전에 순서를 봤다
  8. 큐 워커 운영 장애 연속극: systemd 워커가 안 도는 이유 체크리스트
  9. 문의 폼 봇 방어 5겹 스택, 실제로 뚫린 순서대로
  10. AI 실수 복구: 메인 체크아웃을 dev 상태로 되돌린 기록
  11. CMS 에디터가 내 CSS를 지웠다 — 소개 페이지 삽질기
  12. 구글봇 화이트리스트에 GCP 대역을 넣었다가 발생한 보안 이슈와 교훈

시리즈 소개 보기

시리즈 PHP 취약점 대응 현재 위치 7 / 7
  1. [긴급] PHP RCE(CVE-2026-109618/109619) & CI4 업로드 취약점 대응 가이드
  2. PHP CVE-2026-115560 정리 — 패치 없는 세 번째 RCE, 하지만 한 줄로 막힌다
  3. AI가 찾아낸 14년 묵은 PHP2Shell 취약점과 대응 방법
  4. Ubuntu에서 php -v만으로는 보안 패치를 확인할 수 없는 이유
  5. 법적 위험을 피하는 취약점 스캔, 로컬 CLI 설계
  6. 문의 폼 봇 방어 5겹 스택, 실제로 뚫린 순서대로
  7. 구글봇 화이트리스트에 GCP 대역을 넣었다가 발생한 보안 이슈와 교훈

시리즈 소개 보기

시리즈 AI 시대 보안 점검 현재 위치 11 / 11
  1. "깃헙에 시크릿이 노출됐습니다" 메일을 받았다 — 진짜 위험은 따로 있었다
  2. 사진 동선 앱에서 남의 사진이 보였다 — 위치 데이터를 다룰 때 놓치기 쉬운 것
  3. 멀티테넌트 API에서 company_id를 요청이 아니라 JWT에서 뽑는 이유 — AI 보안 리뷰로 재처리 API 뜯어보기
  4. iframe 하나로 격리하는 웹 채팅 위젯 설계
  5. AI가 만든 실제 실수 4가지 패턴과 교훈
  6. 스캐너 IP 자동 차단: 404 3회 일배치와 즉시 차단, 오차단 방어까지
  7. 리뷰 스팸을 AI로 감시할 때의 운영 규칙: 자동 삭제보다 검토 큐가 먼저인 이유
  8. 문의 폼 봇 방어 5겹 스택, 실제로 뚫린 순서대로
  9. Claude Vision으로 영수증·세금계산서 자동 장부 입력 구현
  10. 어르신들께 AI 목소리 사기를 어떻게 설명할까
  11. 구글봇 화이트리스트에 GCP 대역을 넣었다가 발생한 보안 이슈와 교훈

시리즈 소개 보기

요약: 구글 서치 콘솔이 "403 때문에 색인할 수 없다"고 알려 와서, 스캐너 IP 자동 차단에 구글봇이 걸리지 않도록 구글이 공개한 대역 4개를 화이트리스트에 넣었다. 그런데 그중 34.64.0.0/13은 구글봇 전용이 아니라 GCP 공용 대역이어서, 클라우드를 빌린 누구나 차단을 피할 수 있는 구멍이 됐다. 같은 날 이 대역을 빼고 테스트를 뒤집어 고쳤고, "공개된 대역"과 "신뢰할 수 있는 대역"은 다르다는 교훈을 남겼다.

발단: 서치 콘솔의 403 색인 오류

시작은 구글 서치 콘솔이었다. 색인 보고서에 403 응답 때문에 페이지를 색인하지 못한다는 항목이 나왔다. 사이트는 정상적으로 열리는데 구글봇만 403을 받고 있었다.

배경: 차단 IP 목록에서 구글봇을 지켜야 했다

공개 사이트에는 404 로그 기반으로 스캐너 IP를 자동 차단하는 장치를 두고 있다(이전 글: 스캐너 IP 자동 차단). 차단된 IP는 403을 받는다. 이 장치가 크롤러까지 막을 수 있다는 점이 문제다. 구글봇이 차단되면 색인이 안 되고 검색 노출에 직접 영향이 간다. 서치 콘솔의 403은 이 차단에 구글봇이 걸린 것으로 보고 대응했다.

실제로 차단 목록을 열어 보니 34.64.* 대역 IP가 8개 들어 있었다. 구글이 크롤링에 쓰는 GCP 대역이라, 구글봇이 스캐너로 오인돼 차단된 것으로 판단했다.

해제한 뒤 서치 콘솔의 403 오류는 해소됐고, 색인 재요청도 넣었다. 8개 IP가 원인이었다는 판단이 맞았다.

그래서 이슈를 하나 만들었다. 요청은 두 가지였다.

  • 구글봇 IP 대역 4개를 화이트리스트에 등록한다.
  • 차단 IP 검색에서 *를 와일드카드로 쓸 수 있게 한다. 정확히 일치하는 IP만 검색돼서 찾을 수가 없었다.

이슈에 적은 대역은 다음과 같았다.

66.249.64.0/19   구글봇의 대표 대역
34.64.0.0/13     Google Cloud 인프라를 경유하는 크롤러 대역
34.22.85.0/27
192.178.4.0/22

구현: 단일 IP 비교를 CIDR 비교로

기존 화이트리스트는 .env의 security.ipWhitelist에 쉼표로 적은 단일 IP를 in_array로 비교할 뿐이었다. CIDR을 처리하려면 비교 방식을 바꿔야 했다. inet_pton()으로 IP를 바이트열로 바꾸고, 접두사 비트만큼만 비교하는 IpRange 클래스를 만들었다. IPv4와 IPv6를 모두 처리한다.

public static function containsAny(string $ip, iterable $ranges): bool
{
    $packed = inet_pton($ip);

    if ($packed === false) {
        return false;
    }

    foreach ($ranges as $range) {
        $parts        = explode('/', $range, 2);
        $subnetPacked = inet_pton($parts[0]);

        // 주소 계열(IPv4/IPv6)이 다르면 건너뛴다
        if ($subnetPacked === false || strlen($subnetPacked) !== strlen($packed)) {
            continue;
        }

        $bits = isset($parts[1]) ? (int) $parts[1] : strlen($packed) * 8;

        if (self::matchesPrefix($packed, $subnetPacked, $bits)) {
            return true;
        }
    }

    return false;
}

BlockedIpModel::isWhitelisted()는 내장한 구글봇 대역과 .env의 목록을 합쳐 이 함수로 검사한다.

return IpRange::containsAny($ipAddress, [...self::GOOGLEBOT_RANGES, ...$list]);

검색 와일드카드는 사용자가 입력한 *를 SQL LIKE의 %로 바꾸되, %와 _는 먼저 이스케이프했다. 그렇지 않으면 검색어에 든 _가 임의의 한 글자로 해석된다. 앞뒤는 항상 부분 일치로 두었다.

// 앞뒤는 항상 부분 일치, 입력한 `*` 는 중간 와일드카드로 쓴다.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $keyword);
$builder->where('ip_address LIKE', '%' . str_replace('*', '%', $escaped) . '%');

이제 195.178.110.*로 검색하면 195.178.110.199가 나온다. 테스트를 붙이고 PR을 머지했다.

문제: 대역 하나가 나머지 전부보다 훨씬 컸다

작업 요약을 이슈에 남기면서 구현 내용을 다시 읽다가 걸렸다. 34.64.0.0/13은 구글봇이 쓰는 대역이 아니라 GCP(Google Cloud) 전체 고객이 쓰는 공용 대역이었다. 이 대역에서 나오는 요청이 전부 구글봇은 아니다. GCP에 VM을 빌린 사람이면 누구나 이 대역의 IP를 받는다.

크기를 비교하면 차이가 분명하다. /13은 524,288개, /19는 8,192개다. 화이트리스트에 넣은 네 대역 중 약 98%가 이 하나였다.

화이트리스트 네 대역 중 34.64.0.0/13이 524,288개로 전체의 약 98%를 차지한다

화이트리스트가 면제하는 범위도 문제를 키웠다. isWhitelisted()는 block()에서 쓰이는 데 그치지 않고 isBlocked()에서도 쓰인다. 즉 이 대역의 IP는 자동 차단만 피하는 게 아니라, 관리자가 직접 등록한 수동 차단도 무시됐다.

34.64.1.1이 isWhitelisted()에서 포함으로 판정되면 block()과 isBlocked()가 모두 false를 돌려준다

결과적으로 GCP VM에서 스캐너를 돌리면 우리 차단 장치를 통과하는 구조였다. 구글봇을 보호하려던 기능이 공격자에게 우회로를 준 셈이다.

해결: 대역을 빼고 테스트를 뒤집었다

수정은 한 줄이었다. GOOGLEBOT_RANGES에서 34.64.0.0/13을 지웠다.

private const GOOGLEBOT_RANGES = [
    '66.249.64.0/19',
    '34.22.85.0/27',
    '192.178.4.0/22',
];

더 중요한 건 테스트였다. 원래 테스트는 34.64.1.1이 차단되지 않음을 확인했다. 이걸 "이 대역은 차단된다"는 단언으로 바꿔서, 누군가 나중에 같은 대역을 다시 넣으면 테스트가 깨지게 했다.

foreach (['66.249.66.1', '34.22.85.30', '192.178.5.9'] as $ip) {
    $this->assertFalse($model->block($ip, BlockedIpModel::REASON_AUTO), $ip);
}
// GCP 공용 대역은 구글봇이 아니므로 차단되어야 한다
$this->assertTrue($model->block('34.64.1.1', BlockedIpModel::REASON_AUTO));

운영 대응: 이미 차단된 8개는 수동으로 풀었다

코드를 고쳐도 이미 목록에 들어간 IP는 그대로다. 차단 목록에 있던 34.64.* 대역 8개는 관리자 화면에서 수동으로 해제했다. 이때 같은 이슈에서 만든 와일드카드 검색이 쓸모 있었다. 34.64.*로 검색하면 해당 대역만 한 번에 모인다.

34.64.0.0/13을 화이트리스트에서 뺐으니, 이 대역의 구글 크롤러가 스캐너로 오인되면 앞으로도 자동 차단될 수 있다. 이 부분은 자동화하지 않고 운영으로 풀기로 했다. 서치 콘솔에서 403 오류가 다시 보이면 차단 목록에서 34.64.*를 검색해 수동 해제한다.

구글봇을 지키는 것과 GCP 전체에 구멍을 내지 않는 것은 서로 트레이드오프라서, 면제 범위를 좁히는 대신 오탐은 사람이 푸는 쪽을 택했다.

배운 점

  • 공개된 대역이라고 신뢰할 수 있는 건 아니다. 클라우드 사업자의 대역에는 그 회사의 서비스뿐 아니라 고객의 서버도 섞여 있다. 화이트리스트에는 "그 서비스만" 쓰는 대역만 넣어야 한다.
  • 화이트리스트를 넣을 때는 크기부터 본다. CIDR 접미사 하나(/13 vs /19)가 면제 대상을 64배 키운다. 목록에서 유독 큰 항목이 있으면 의심한다.
  • 면제는 어디까지 적용되는지 확인한다. 이번에는 자동 차단뿐 아니라 수동 차단까지 풀렸다. 예외 규칙은 적용 범위가 넓을수록 위험하다.
  • 정말 구글봇인지 확인하려면 IP 대역보다 역방향 DNS 검증이 더 정확하다. 구글 문서가 안내하는 방식(IP를 역조회해 googlebot.com 도메인인지 보고, 그 이름을 정방향 조회해 같은 IP인지 확인)이다. 이번에는 적용하지 않았다.
  • 보안 장치는 검색 노출에 부작용을 낸다. 자동 차단은 스캐너뿐 아니라 구글봇도 403으로 막을 수 있다. 차단 로직을 넣었다면 서치 콘솔의 크롤링·색인 오류를 주기적으로 확인한다.
  • 실수를 만든 순간이 아니라 작업 요약을 쓰다가 발견했다. 이슈에 쓰는 요약이 사실상 두 번째 리뷰 역할을 했다.

이어 읽을 글

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.