쓰레기로 가득한 ELK 대시보드 청소기

프로필

2026년 03월 18일

11 0

포스트로는 따로 작성하지 않았던 개선이 하나 있다. Nginx의 설정파일 변경이다.
진행한 이유는 간단명료하다. Kibana로 확인하는 로그 중 40~50% 정도가 /env, /git 등의 실제로 존재하지 않는 경로를 탐색하는 쓰레기 같은 스캐너 로그라 ELK 스택을 도입하게 된 의미가 퇴색되었기 때문이다.

그래서 Kibana에 반복적으로 등장하는 경로들을 기준으로 444를 반환하도록 Nginx의 설정을 수정했다.
404가 아닌 444를 반환한 이유는 후술하겠다.

(php|php\d?|phtml|wp-admin|wp-content|wp-includes|wp-json|wp-login\.php|xmlrpc\.php|env|git|config|ht|cgi-bin|alfacgiapi|pl|cgi|asp|aspx|jsp|
?:html?|shtml)$ { return 444; } # 블랙리스트 예시

물론 실제 설정은 위처럼 단일 블록이 아닌, 여러 location 블록으로 나눠서 적용했다.
그 이후로 스캐너 로그가 약 40~50%에서 20%대로 줄어드는 긍정적인 결과를 확인할 수 있었다.

그런데 어제 우연히 Kibana 로그를 확인하던 중 127.0.0.1에서 7초 만에 60번의 POST 요청을 보낸 흔적을 발견했는데, 난 그 시간에 접속한 적이 없었다. 또한 나는 내부에서 직접 요청을 보낼 일이 거의 없기에 침입을 의심할 수 밖에 없었다.

패턴 또한 /api/upwload, /admin/upload 등의 전형적인 스캐닝 패턴이었기에 OCI의 Security rules의 인바운드/아웃바운드 설정을 확인해보고, 직접 터미널에서 curl을 통해 서버 ip:8000로 접속해봤지만

curl: (28) Failed to connect to cliche.blog port 8000 after 134528 ms: Couldn't connect to server

정상적으로 타임아웃이 뜨는 걸 확인했고, last 명령어를 통해 인스턴스의 접속 기록을 확인해봤을 때, 로그의 시간대에는 나 혹은 다른 사람의 접속 기록은 없었기 때문에 내부 침입이라는 가설은 틀렸다는 걸 확인했다.

그렇다면 저 127.0.0.1의 정체는 무엇일까?

ps -ef로 프로세스를 확인해봤을 때 가장 확률이 높은 건 Logstash 혹은 Filebeat 였는데, 곰곰이 생각해보니 로그 수집기가 실제로 존재하지도 않는 api/upwload와 같은 오타 가득한 경로에 POST를 때릴 이유가 없었다.

슬슬 머리가 아파오기 시작했고, AI에게 의견을 물어봤다.


밝혀진 정체

그럼 남은 가능성은 사용자님이 사용하고 계시는 OCI(Oracle Cloud Infrastructure)의 Vulnerability Scanning 서비스일 확률이 높습니다.

내 인스턴스에는 오라클에서 제공하는 취약점 스캐닝(Vulnerability Scanning) 서비스가 활성화되어 있었다.
이 서비스는 외부에서 내부를 찔러 확인하는 방식이 아닌, 성 안의 관리인(에이전트)이 직접 돌아다니며 문단속을 하는 형태였다.

하긴 생각해보면 7초에 60번이라는 속도는 일단 인간이 손수 보낼 수 있는 속도는 아닐 뿐더러, 외부 침입이라고 하기엔 접속 기록도, OCI의 방화벽도 굳건한 상태였기에 에이전트라고 가정하면 얼추 퍼즐이 맞아 떨어졌다.

하지만 오타 섞인 경로에 조금 의구심이 들었는데 이 또한 전문적인 보안 스캐너의 특징이었다.
스캔을 진행하면서 개발자의 실수로 방치된 오타 API 경로를 확인하기 위해 전형적인 실수 패턴(Fuzzing Wordlist)를 자동으로 확인하는 기능이 존재하기 때문이었다.


우연찮게 발견한 '뒷문'

해프닝으로 끝날 뻔한 이 사건은 모든 보안 인프라를 뒤집게 하는 시발점이 되었는데, 외부 침투를 상정하고 경로를 추론해보며 내 Docker 설정이 0.0.0.0:8000으로 열려있었다는 점을 깨달았다.

지금까지는 없었지만 혹시라도 OCI의 설정이 어떤 이유로든(설정 실수 등) 무너지는 순간, 내 FastAPI 서버는 맨 몸으로 전 세계 스캐너들의 먹잇감이 될 수 있다는 뜻이었다.

바로 급하게 Docker에 바인딩 제한을 걸었다.
ports: - "8000:8000"127.0.0.1:8000:8000으로 외부 패킷은 Nginx를 거치지 않고서는 통과할 수 없게 설정을 수정했다.


차단 방식의 전환

기존 블랙리스트 방식을 기상천외한 경로로 뚫고 들어오는 나머지 20~30%의 봇을 차단하기 위해서 블랙리스트가 아닌 화이트리스트로 방식을 전환하기로 결정했다.

간단하게 설명하자면, 블랙리스트는 술집에서 문제를 일으키는 경우 다음부터 입장불가 조치를 하는거고, 화이트리스트는 회원제 바로 기존에 명단에 있는 회원이 아니면 입장할 수 없는 방식이다. 즉, 내가 허락한 경로 이외에는 모두 거절하는 방식인데 이 또한 404(Not Found)가 아닌 444(No Response)를 반환하는 형태로 진행했다.

표준 응답인 404가 아닌 444를 선택한 이유는 404는 서버가 클라이언트의 요청을 이해했지만, 그 경로에 리소스가 없다고 알려주는 방식이라 경로에 리소스가 없음을 확인 후 헤더와 바디가 포함된 표준 응답 패킷을 생성하여 전송하는 방식이라 서버 리소스, 패킷 전송을 위한 아웃바운드 트래픽이 발생한다.

하지만 444의 경우, Nginx에서만 사용되는 비표준이지만, 클라이언트의 요청을 받으면 Nginx가 화이트리스트를 확인하고 허용되지 않은 경로임을 인지하는 순간 대답 없이 연결을 끊어버리는 방식이라 HTTP 응답 패킷 생성도, 전송도 없고, 트래픽 낭비도, FastAPI에 가해지는 부하도 사실상 없다.

404는 벨이 울리면 전화를 받고, 광고 전화면 "관심 없어요"하고 끊는 방식이고, 444는 모르는 번호는 아예 벨소리도 안 울리게 하는 방식이라 상대방은 일부러 전화를 안 받는건지, 신호가 안 가는건지 알 수 없게 하는 차이라고 할 수 있다. 즉, 봇 입장에서는 서버가 죽은 건지, 네트워크가 불안정한 건지 알 수가 없어 재시도 로직 자원을 소모하게 된다.

물론 모든 기술결정에는 트레이드 오프가 따른다는 불변의 진리에 따라 이 방식 또한 단점은 존재한다.

일단 첫 번째로는 운영관리의 귀찮음이 있는데, 기존에 허용하지 않은 경로는 모조리 444를 반환하는 방식이기에 라우트를 추가할 때마다 Nginx 화이트리스트에 새로 추가될 라우트를 설정해주고 재시작하는 일련의 과정이 추가된다.

두 번째로는 SEO/UX적 리스크다. 세상의 모든 봇이 나쁜 것만은 아니다. 나쁜 봇이 있으면 반대급부로 착한 봇도 존재하기 마련, 그 착한 봇들은 바로 검색엔진 봇이다(구글, 네이버 등). 검색엔진 봇은 sitemap.xml에 적힌 링크들을 하나씩 크롤링하려고 시도하는데 이때 개별 포스트 경로나 정적 파일(이미지 등)의 경로 중 하나라도 화이트리스트 정규식에서 누락된 경우 봇은 444를 맞고 '이 사이트는 링크는 존재하지만 실제 페이지는 없네'라고 판단하고 인덱싱을 포기한다. 또한 444는 표준 응답이 아니라 봇은 이를 '일시적인 서버 장애'로 오해하고 나중에 다시 방문하는 데 이게 계속 반복되면 페이지 전체의 점수가 떨어질 수 밖에 없는 구조다. UX면에서는 정적 자원이 화이트리스트에서 누락된 경우 리소스가 차단되어 소셜 공유 등에서 미리보기가 뜨지 않아 신뢰도가 하락하는 결과를 낳게 된다.

허나 나의 경우에는 실보다는 득이 컸는데 운영관리의 귀찮음은 사실 블랙리스트 방식때와 별반 차이가 없다. 왜냐면 어차피 기존 방식도 로그에서 반복적으로 등장하는 키워드를 매번 location 블록에 추가하는 형태라 새로운 라우트를 화이트리스트에 추가하는 것과 큰 공수차이가 없고, SEO 리스크 또한 정적 자원(이미지)을 인스턴스에 직접 저장하는 방식이 아닌 외부 인프라(S3)를 사용해서 그대로 서빙하는 방식이라 리소스가 차단되는 건 피할 수 있고, sitemap.xml도 FastAPI에서 db.query를 이용해서 실시간으로 생성하는 로직을 작성하여 화이트리스트들을 모두 공개하도록 수정했다. 또한 내 블로그는 외부 검색에서 발생하는 트래픽보다는 지인이나 포트폴리오, 깃허브, 링크드인에서 타고 들어오는 경우가 대부분이라 404를 씀으로써 사용하게 되는 리소스보다 444로 봇에게 확실하게 엿을 먹이고 트래픽을 절감하는 게 더 효율적이라는 판단이 섰기 때문이다.

Kibana 로그를 다시 확인해봤는데 전체 기간 기준 정상응답(200)의 비율이 63%, 비정상응답(404)의 비율이 36.4%였다. 이 프로젝트가 토이가 아닌 MAU가 꽤나 나오는 프로덕트라고 가정을 한다면 그만큼 불필요한 응답 처리와 아웃바운드 트래픽이 발생하고 있었다는 뜻이다. 프로젝트 규모가 커질수록 이런 낭비는 무시하기 어려운 비용이 된다.


아직 한 발 남았다

또한 마지막으로 fail2ban에 필터를 추가했다. Nginx에서 444 응답을 받은 IP를 실시간으로 감지하여 커널 레벨에서 영구적으로 차단하는 로직을 구축했다.

[nginx-444]
enabled = true
port    = http,https
filter  = nginx-444
logpath = /var/log/nginx/access.log

bantime = 600
bantime.increment = true
bantime.multipliers = 1 3 -1
bantime.maxtime = -1

findtime = 60
maxretry = 11

위는 최종 설정이다.
처음에는 findtime을 600, maxretry를 3으로 선의의 피해자(동종업계 종사자)와 휴먼에러를 고려해 한국의 전통적 국룰인 삼진아웃제를 도입하려고 했는데 내가 테스트한 브라우저(Chrome, Safari)들은 444를 맞을 경우 표준 응답이 아니라 네트워크 장애로 오해하고 재접속을 시도하는 걸로 의심되는 현상이 있어 폰으로 테스트 하던 도중 한번 접속에 로그 4~5개가 올라가며 즉시 밴을 먹는 걸 확인하고, unbanip를 수차례 입력하고 테스트하며 브라우저의 재시도를 고려한 최적의 설정을 찾는 과정이 있었다.


결론과 1일차

질리도록 들었다. 포트폴리오의 최고봉은 실제로 운영해 본 서비스라고, 근데 딱히 난 그 의견에 깊게 공감하지는 않았었다. 사실 지금 이 블로그는 내가 개발을 시작한 때와는 상전벽해 수준으로 달라진 AI 에이전트들의 코딩 성능에 따라 지금 만들라고 하면 훨씬 짧은 시간안에 지금보다 더 높은 완성도로 만들 수 있다고 자신한다.

근데 다시 생각해보면 여태껏 내가 작성한 포스팅들은 그 AI들이 쉽게 가르쳐 줄 수 없는 부분이라는 생각이 들었다. 왜냐면 AI는 결국 가장 보편적이고, 안전한 답변을 하도록 설계되어 있기 때문이다. 화이트리스트 방식을 도입하면 필연적으로 따라오는 운영/SEO 리스크보다는 보편적인 블랙리스트 방식을 추천할 것이고, 비표준인 444보다는 표준 응답인 404를 추천할 것이기 때문이다. 즉, 오늘의 삽질도 꽤 오래전, 그러니까 블로그를 만든 초창기에 느낀 불편함이 날 여기까지 오게했고, 만약 이 블로그를 단순 포트폴리오용으로 제작만 하고 배포를 하지 않았다면 쉽게 얻을 수 없었던 인사이트라고 생각한다.

터미널에서 로그를 보는 게 귀찮아서 도입하게 된 ELK, ELK를 도입했음에도 끊이지 않는 스캐닝에 지쳐 도입한 Fail2Ban, 그럼에도 온갖 경로를 쑤시는 봇들 때문에 오늘의 수정까지. 다음엔 또 어떤 해프닝이 날 엿먹이고, 또 어떤 인사이트를 선물해줄 지 기대가 된다.

참고로 포스팅 초안 자체는 어제 작성을 마쳤지만 디테일 수정을 하는데 시간이 걸려서 오늘 업로드하게 됐는데 그 하루 동안 444 응답은 1696회, 차단된 아이피는 52개다.

또한 Kibana의 로그 역시 당연한 말이지만 상태 코드를 기준으로 조회하면 내가 테스트로 진행한 404 1회와 405, 303을 제외한 97.7%의 응답이 200이라는 결과를 보여준다.

#Nginx #Fail2ban #ELK #Kibana #Whitelist

댓글 0개

댓글을 작성하려면 로그인이 필요합니다