이전 단계

Cloudflare의 고유한 헤더 정보를 알아본 다음, 방문자 IP를 실제로 얻어서 로그에 활용하는 방법을 알아보았습니다.

실제 서버가 발각되면 어떻게 될까

Reverse Proxy 서버로 훌륭한 역할을 해주고 있는 Cloudflare지만, 원리상 DNS 레코드에 자기 자신이 대상으로 들어가서 중개해주는 것이라는 한계가 있습니다. 사실 이를 뒤집어 말하면, 중개업자를 거치지 않고 직접 가버리면 된다는 무방비라는 말이 될 수도 있습니다.

간단히 비유하면, 교수님의 연락처를 공개하지 않고 연구소 사무실에 상주하면서 외부인사가 기자들의 연락을 받는 조교가 있다고 칩시다. 조교가 불필요한 연락을 모두 쳐내고 필수적인 것만 교수에게 연결하면 교수의 전화기도 평화로울 것입니다. 하지만 교수의 연락처가 부주의하게 노출되고 논란이 터져 기자의 눈에 포착이 되면, 교수의 전화기는 조교가 아무리 열심히 연락을 끊어도 시끄럽게 울릴테죠.

Cloudflare에는 Challenge1)의심스러운 사용자에게 지연 시간과 버튼 인증을 거는 것, 봇이라면 막힐 것이고 사람의 공격이라면 지연 시간 탓에 서버에 충분한 피해를 주기 어려워집니다.를 비롯한 다양한 보안 장치가 있습니다. 내 실제 IP가 노출된다는 건 이런 보안 장치를 다 건너뛰고 문을 두드릴 수 있다는 소리에요.

좋은 방법이 없을까요?

미리 일러두기: 이번 글의 방법 말고 비슷한 효과를 내는 다른 방법을 원하시면 다음 글로 넘어가셔도 좋습니다.

헤더가 없으면 Cloudflare를 거쳐오지 않았다고 봅시다

해결 원리는 간단합니다. Cloudflare가 붙여준 헤더가 없으면 현관문을 잠그고 벨을 무시하는 것이죠.

다시 nginx 설정 파일의 location / {} 절로 이동해봅시다. 참고로 location ~* \.php$ {} 같은 게 있어도 응답이 출력되므로, 둘 다 적용해야 한다. 절의 가장 위에 이와 같은 코드를 추가한다. 추가 후 sudo nginx -t, sudo service nginx reload까지 해준다.

if ($http_cf_connecting_ip = "") {
    return 444;
}

왜 HTTP 444 에러인가

HTTP 444는 Nginx 전용 응답 코드로, 아예 응답을 하지 않습니다.

공격자의 의도대로 에러메시지 응답을 출력하는 것만으로도 서버 부담을 줄 수 있습니다. 부적절한 접근에 대해 아예 응답을 하지 않아버림으로써 서비스도 보전하고, 공격자는 자신의 공격이 적중했는지 알 수도 없습니다.

예기치 못한 경우의 수

WordPress를 쓰고 있는데 Site Health가 경고를 4개 정도 내는 것을 알게 됐습니다. 루프백 요청이 실패했습니다, 스케쥴러가 동작하지 않습니다. REST API에 오류가 발생했습니다, 등등 문제가 생긴 것이죠.

로그를 살펴보니, 127.0.0.1에서 시작한 요청이 444 코드를 받고 연결이 끊기는 것을 확인할 수 있었습니다.

그러면 어떻게 해야 할까요? nginx.conf를 또 뜯어볼 수밖에 없습니다.

map $remote_addr $cf_challenge {
    default $http_cf_connecting_ip;
    127.0.0.1 local;
    ::1 local;
}

$remote_addr는 Nginx가 확인한 접속자의 IP 변수입니다. 일반적인 경우에는 Cloudflare가 보내온 IP로 하고, 미리 지정한 예외 상황에만 로컬이라는 문자열을 넣어주었습니다.

if ($cf_challenge = "") {
    return 444;
}

이렇게 변수 $cf_challenge만 체크하면 로컬인 경우는 제외하고 걸러낼 수 있게 됩니다. nginx 설정에는 if가 없음에 잠깐 당혹스러울 수 있지만, 이렇게 변수를 활용하면 복합 조건도 체크가 가능합니다.

테스트

$ curl -v https://yourdomain.com/ --resolve yourdomain.com:443:<realip> > /dev/null

<realip>와 yourdomain.com만 올바르게 변경하면 됩니다. 요청을 보내보면, 예기치 않게 연결이 끊겼다는 결과 메시지를 확인할 수 있습니다.

* Added yourdomain.com:443:<realip> to DNS cache
* Rebuilt URL to: https://yourdomain.com/
* Hostname yourdomain.com was found in DNS cache
*   Trying <realip>...
* TCP_NODELAY set
* Connected to yourdomain.com (<realip>) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Client hello (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-CHACHA20-POLY1305
* ALPN, server accepted to use h2
* Server certificate:
*  subject: CN=*.yourdomain.com
*  start date: May  6 21:46:37 2020 GMT
*  expire date: Aug  4 21:46:37 2020 GMT
*  subjectAltName: host "yourdomain.com" matched cert's "yourdomain.com"
*  issuer: C=US; O=Let's Encrypt; CN=Let's Encrypt Authority X3
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x7fffc8718b70)
> GET / HTTP/2
> Host: yourdomain.com
> User-Agent: curl/7.58.0
> Accept: */*
>
* Connection state changed (MAX_CONCURRENT_STREAMS updated)!
* HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1)
* Connection #0 to host yourdomain.com left intact
curl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1)

의도대로 HTTP 응답은 출력되기 전에 닫혀버렸습니다. 그럼 비교군의 테스트도 해봐야겠군요.

$ curl -v https://yourdomain.com/ > /dev/null
* Rebuilt URL to: https://yourdomain.com/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0*   Trying <proxyip>...
* TCP_NODELAY set
* Connected to yourdomain.com (<proxyip>) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
} [5 bytes data]
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
} [207 bytes data]
* TLSv1.2 (IN), TLS handshake, Server hello (2):
{ [104 bytes data]
* TLSv1.2 (IN), TLS handshake, Certificate (11):
{ [2200 bytes data]
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
{ [115 bytes data]
* TLSv1.2 (IN), TLS handshake, Server finished (14):
{ [4 bytes data]
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
} [37 bytes data]
* TLSv1.2 (OUT), TLS change cipher, Client hello (1):
} [1 bytes data]
* TLSv1.2 (OUT), TLS handshake, Finished (20):
} [16 bytes data]
* TLSv1.2 (IN), TLS handshake, Finished (20):
{ [16 bytes data]
* SSL connection using TLSv1.2 / ECDHE-ECDSA-CHACHA20-POLY1305
* ALPN, server accepted to use h2
* Server certificate:
*  subject: C=US; ST=CA; L=San Francisco; O=Cloudflare, Inc.; CN=sni.cloudflaressl.com
*  start date: May  6 00:00:00 2020 GMT
*  expire date: Oct  9 12:00:00 2020 GMT
*  subjectAltName: host "yourdomain.com" matched cert's "yourdomain.com"
*  issuer: C=US; ST=CA; L=San Francisco; O=CloudFlare, Inc.; CN=CloudFlare Inc ECC CA-2
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
} [5 bytes data]
* Using Stream ID: 1 (easy handle 0x7ffff0659b70)
} [5 bytes data]
> GET / HTTP/2
> Host: yourdomain.com
> User-Agent: curl/7.58.0
> Accept: */*
>
{ [5 bytes data]
* Connection state changed (MAX_CONCURRENT_STREAMS updated)!
} [5 bytes data]
< HTTP/2 200
< date: Sun, 17 May 2020 13:56:45 GMT
< content-type: text/html
< set-cookie: __cfduid=ddbf3fefd304f8f294276470d6c6e583b1589723805; expires=Tue, 16-Jun-20 13:56:45 GMT; path=/; domain=.yourdomain.com; HttpOnly; SameSite=Lax; Secure
< last-modified: Tue, 31 Mar 2020 04:21:47 GMT
< expires: Sun, 17 May 2020 13:56:44 GMT
< cache-control: no-cache
< cf-cache-status: REVALIDATED
< accept-ranges: bytes
< expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
< strict-transport-security: max-age=15552000
< server: cloudflare
< cf-ray: 594dd7f9aa330ac8-NRT
< alt-svc: h3-27=":443"; ma=86400, h3-25=":443"; ma=86400, h3-24=":443"; ma=86400, h3-23=":443"; ma=86400
< cf-request-id: 02c485500400000ac833a2b200000001
<
{ [881 bytes data]
100 22703    0 22703    0     0  94991      0 --:--:-- --:--:-- --:--:-- 94991
* Connection #0 to host yourdomain.com left intact

resolve 없이 정상적으로 입력하면 정상적으로 출력되는 것을 확인할 수 있습니다. 물론 브라우저도 정상적으로 작동하니, 정상 유저의 영향 또한 없을 것이고요.

다음 단계

Cloudflare 시리즈는 계속됩니다. 비슷한 방법을 클라이언트 인증서로 해결하는 방법에 대해 알아보시려면 다음 글로 넘어가세요.

2년차 개발자 이진백입니다. 현재 일본에서 웹 프로그래머로 근무중입니다. 주로 Java, Vue (Nuxt), TypeScript 언어를 활용하고 있습니다. 취미로 C#과 Windows Universal Store App을 오래 만져왔고, 개인 서버 운영에도 관심이 많습니다. 최신 이슈를 알기 쉽게 전달해드리도록, 더욱 더 노력하겠습니다.

주석   [ + ]

1. 의심스러운 사용자에게 지연 시간과 버튼 인증을 거는 것, 봇이라면 막힐 것이고 사람의 공격이라면 지연 시간 탓에 서버에 충분한 피해를 주기 어려워집니다.