이전 단계

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 없이 정상적으로 입력하면 정상적으로 출력되는 것을 확인할 수 있다. 물론 브라우저도 정상적으로 작동하니, 정상 유저의 영향 또한 없다.

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

주석   [ + ]

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