서버를 운영한다는 건 가게를 운영하는 거랑 별 다를 바가 없다는 생각을 요즘들어 하게 됩니다. 일정한 포트를 통해 밖으로 드러나야 하며, API만 하더라도 JSON으로, HTML 웹사이트라면 HTML이라는 마크업 언어로 접객을 해야 하고, 주소(도메인)를 가져야 하며, 영업중이 아닌 곳은 철저히 막아서 카운터에 거수자가 난입하지 못하게 해야 하지요. 모두 실제로 하나라도 어그러지면 손님이 제대로 찾아올래야 올 수가 없으니까요.

또 영업을 잘 하다가 외부의 요인으로 심각하게 피해를 입은 뒤에는 손님들에게 신뢰를 주기에도 어렵고요. 자료를 도둑맞거나 이미 내 서버를 거점으로 또다른 피해를 주변에 줬을지도 모를 일입니다.

그런 사고를 막으면서 정상적으로 영업을 하기 위해서는, 실생활과 유사한 방식으로 공격자의 패턴에 접근할 필요가 있을 것 같습니다.

알려진 포트를 모두 건드려보는 수상한 시도

현관문은 열려 있나? 그러면 우유투입구는 열려 있나? 초인종을 누르면 누가 자동으로 열어주지 않나? 정원에 창문은 열려 있나? 가스배관 옆 창문은 열려 있나?

로그를 보면 이들의 시도는 정말 끈질기고 고약합니다. 알려진 포트는 일단 찔러 보지 않으면 성미가 풀리지 않는 지독한 프로그램 공격이에요.

  • SSL (TCP 22)
  • FTP (TCP 21)

여기에 대한 처방은 익히 알려져 있어서 나열만 하고 넘어가려고 합니다.

  • 포트 설정 변경 (SSL, FTP)
  • 경로 변경 (HTTP)
  • 접속자 지역, 주소 제한
  • FAIL2BAN 적용
  • 관리자 통지 시스템 도입

블로그 관리자 페이지에 admin으로 무작정 돌격하는 미상의 로그인 시도

너의 아이디는 모르겠지만 블로그를 빼앗고 싶어. 비밀번호는 천천히 알아가면 되니까.

가장 흔하게 알림이 오는 패턴입니다. 크게 세 가지 처방이 있을 수 있겠습니다.

  • iTheme Security 같은 보안 플러그인으로 Challenge 도입
    • Fail2Ban처럼 실패한 로그인 횟수를 측정하여 일정 수준에 도달하면 일시적으로 차단합니다.
    • nginx 설정과 연계하여 영구적 차단을 걸 수도 있습니다.
  • 지역 제한
    • 관리자 페이지 로그인은 관리자만 할 것이기 때문에 관리자가 있을 법한 나라로 한정하면 됩니다. 보통 공격은 해외발로 이뤄집니다.
    • 물론 그들도 VPN이나 실제 국내 공격자일 수도 있어요. 이처럼 국내를 거쳐서 들어오는 방법은 충분히 있으므로 안전하다 볼 수는 없지만 방어의 범위를 줄이는데 큰 공헌을 합니다. GeoIP와 Cloudflare 헤더 판별을 활용해보니 쓸만했습니다.
  • 얼토당토 않은 아이디를 대면 바로 거부
    • admin, root, administrator 같은 아이디로 마구 돌격하는 로그가 쌓여있다고 해봅시다. 이런 로그인 시도는 명백히 관리자 권한을 얻고 싶다는 것이죠. 따라서 이런 걸 시도했다는 것 자체가 공격자라는 확증이 됩니다.
    • 당연히 영구 차단을 걸게 설정하면 됩니다. (iTheme Security 기능)

HTML 서버에 힌트를 찾아서

현관문 옆 화분, 우유투입구 안쪽 바닥, 수도관함 내부, 층계참, 자동차 앞유리, 버려진 택배상자, 어딘가에 빼내기 좋은 힌트가 숨어있을거야.

요즘들어 로그를 보면서 조금 소름이 돋는 지점이 이것입니다. 이 서버에 접속을 했더니 HTTP 포트가 멀쩡히 열려있다는 걸 알게 되면, 공격자는 서버가 무슨 역할을 하는지 알고 싶은 모양입니다. 그래서 서버 웹페이지에 링크로 존재하지도 않는 걸 쑥쑥 찔러서 열어보려고 시도합니다.

[error] 14729#14729: *13731 open() "/etc/nginx/html/stalker_portal/c/version.js" failed (2: No such file or directory), client: 134.122.104.168, server: *, request: "GET /stalker_portal/c/version.js HTTP/1.1"
[error] 14729#14729: *13735 "/etc/nginx/html/client_area/index.html" is not found (2: No such file or directory), client: 134.122.104.168, server: *, request: "GET /client_area/ HTTP/1.1"
[error] 14729#14729: *13739 open() "/etc/nginx/html/system_api.php" failed (2: No such file or directory), client: 134.122.104.168, server: *, request: "GET /system_api.php HTTP/1.1"
[error] 14729#14729: *13743 open() "/etc/nginx/html/streaming/clients_live.php" failed (2: No such file or directory), client: 134.122.104.168, server: *, request: "GET /streaming/clients_live.php HTTP/1.1"
[error] 14729#14729: *13747 "/etc/nginx/html/stalker_portal/c/index.html" is not found (2: No such file or directory), client: 134.122.104.168, server: *, request: "GET /stalker_portal/c/ HTTP/1.1"
[error] 14729#14729: *13751 open() "/etc/nginx/html/api.php" failed (2: No such file or directory), client: 134.122.104.168, server: *, request: "GET /api.php HTTP/1.1"
[error] 14729#14729: *13755 open() "/etc/nginx/html/login.php" failed (2: No such file or directory), client: 134.122.104.168, server: *, request: "GET /login.php HTTP/1.1"
[error] 14729#14729: *13761 open() "/etc/nginx/html/streaming" failed (2: No such file or directory), client: 134.122.104.168, server: *, request: "GET /streaming HTTP/1.1"
[error] 14729#14729: *13764 open() "/etc/nginx/html/streaming/clients_live.php" failed (2: No such file or directory), client: 134.122.104.168, server: *, request: "GET /streaming/clients_live.php HTTP/1.1"
[error] 14729#14729: *13767 open() "/etc/nginx/html/streaming/9shcPxG9Vl.php" failed (2: No such file or directory), client: 134.122.104.168, server: *, request: "GET /streaming/9shcPxG9Vl.php HTTP/1.1"
[error] 14729#14729: *13791 open() "/etc/nginx/html/owa/auth/logon.aspx" failed (2: No such file or directory), client: 162.243.139.192, server: *, request: "GET /owa/auth/logon.aspx?url=https%3a%2f%2f1%2fecp%2f HTTP/1.1"
[error] 14729#14729: *13795 open() "/etc/nginx/html/GponForm/diag_Form" failed (2: No such file or directory), client: 185.172.111.210, server: *, request: "POST /GponForm/diag_Form?style/ HTTP/1.1"
[error] 14729#14729: *13796 open() "/etc/nginx/html/MAPI/API" failed (2: No such file or directory), client: 45.9.148.91, server: *, request: "GET /MAPI/API HTTP/1.1"

위 복수의 공격자는 각각 이 서버를 stalker_portal 또는 Outlook Web App (취약점), Dasan GPON 라우터 (취약점), OWASP API 기능으로 착각한 듯 합니다.

일단 제가 운영하는 각기 다른 서버의 액세스 기록을 살펴보기로 했습니다.

공격자의 시도 리스트

/.env
/.git/HEAD
/admin/
/admin/index.php?route=common/login
/admin/login.php
/administrator/index.php
/back/backup.tar.gz
/back/bak.gz
/back/bak.tar.gz
/back/directory.zip
/back/public_html.rar
/back/sql.sql
/back/www.tar.gz
/back/yeon.me.sql
/back/yeon.me.tar.gz
/back/yeon.me.zip
/backup.sql.gz
/backup/backup.gz
/backup/backup.sql
/backup/backup.tar.gz
/backup/bak.gz
/backup/directory.gz
/backup/public_html.tar.gz
/backup/public_html.zip
/backup/sftp-config.json
/backup/website.gz
/backup/website.tar.gz
/backup/website.zip
/backup/www.rar
/backup/www.sql
/backup/www.zip
/backup/yeon.me.sql
/backup/yeon.me.zip
/backups/backup.rar
/backups/backup.tar.gz
/backups/bak.tar.gz
/backups/mysql.sql
/backups/public_html.zip
/backups/sftp-config.json
/backups/www.gz
/backups/www.sql
/backups/yeon.me.rar
/backups/yeon.me.tar.gz
/backups/yeon.me.zip
/bak.tar.gz
/bak.zip
/bak/backup.sql.gz
/bak/bak.rar
/bak/directory.gz
/bak/directory.zip
/bak/sftp-config.json
/bak/sql.sql
/bak/website.gz
/bak/www.gz
/bak/yeon.me.tar.gz
/bak/yeon.me.zip
/directory.rar
/feed
/hc8Z
/old/backup.rar
/old/backup.sql
/old/backup.tar.gz
/old/backup.zip
/old/bak.zip
/old/directory.rar
/old/mysql.sql
/old/public_html.rar
/old/public_html.tar.gz
/old/public_html.zip
/old/website.tar.gz
/old/website.zip
/old/www.tar.gz
/restore/backup.sql
/restore/backup.tar.gz
/restore/backup.zip
/restore/bak.gz
/restore/bak.rar
/restore/bak.tar.gz
/restore/bak.zip
/restore/directory.tar.gz
/restore/dump.sql
/restore/mysql.sql
/restore/public_html.tar.gz
/restore/www.gz
/restore/www.tar.gz
/sftp-config.json
/website.rar
/wp-content/plugins/jquery-html5-file-upload/readme.txt
/wp-login.php
/www.gz
/www.rar
/www.sql
/www.zip
/xmlrpc.php
/yeon.me.gz

특히 눈에 띄는 것은 뭔지 모를 admin 디렉토리, 그리고 사이트 전체 압축 파일을 백업파일로 그대로 갖고 있으리라는 추측을 하고 있는 점입니다. 서버를 통째로 가져갈 수 있는 제일 손쉬운 방법이지요.

일반적인 리눅스 파일 시스템은 퍼미션이 있고, 일부는 HTTP 프로세서가 접근할 수 없는 퍼미션으로 지정되어 있기 마련입니다. 하지만 만약 저런 백업 파일이 존재한다면, 백업이 올바로 작동하기 위해서라도 높은 권한으로 다 넣어놨을테니, 해커는 우리가 싸놓은 보따리만 가져가면 모든 걸 원하는 대로 가져갈 수 있게 되는 셈입니다.

그 외 많은 공개 프로그램의 디렉토리가 맞으면 당첨이고, 아니면 말고. 그러니까 기본 디렉토리를 쓰는 건 꽤 위험하다는 것을 알 수 있습니다.

그래서 워드프레스는 진작에 다른 디렉토리로 바꿔봤는데 소용이 없었습니다. 알려진 디렉토리나 의심가는 곳은 미리 패턴화 해놓은 모양이지요.

이걸 막기 위해 각 소프트웨어의 최신화와 보안 패치, 액세스 지역 제한, 로그인 시도 제한을 모두 걸어서 방어에 철저를 기해야겠습니다.

  • 수상한 접속은 Nginx 444 같은 무응답으로 끊어버리기
  • HTTP 403, 500 등 공격자에게 다양한 정보를 주는 오류를 404로 통합해버리기

분산 서비스 거부 공격 (DDoS)

서버 내부를 알 필요도 없이, 통상적인 방법으로 동시다발적으로 집요하게 물고 늘어져서 정상적인 서버 역할을 할 수 없게 만들어버리겠어.

DDoS가 뭐더라 하는 당신, 수강 신청 서버가 맨날 예비 수강생한테 당하는 게 바로 이겁니다. 내 서버를 다수의 서버에서 일시에 공격을 해서 정신을 차리지 못하게 만들어버릴 수 있습니다.

사실 DDoS 방어는 일개 프론트 서버에게 요구하기엔 너무 가혹한 경우가 많습니다. 거대 사이트는 로드 밸런서를 통해 복수의 서버로 요청을 분산하여 어느 정도 서버 자원의 여유를 확보하는데, DDoS는 클라이언트가 불특정 다수로 감염 등 모종의 이유로 프로그램이 설치되고 지령을 받아서 일시에 공격이 이뤄집니다.

이는 자동 로드 밸런서 확장을 통해 물량(과 자금)으로 견디는게 가장 무난한 방법입니다. 하지만 개인 사이트에서 이런 걸 고민하기엔 금액이 많이 듭니다.

Cloudflare를 선택한 것도 그래서입니다. 원한을 산 적은 없지만 누가 공격하면 Cloudflare가 대신 응답을 받아주고 반복적인 요청을 할 수 없도록 내 서버로의 연결을 지연해줍니다. 또 Cloudflare 뒤에 숨는 것만으로도 자체 방화벽과 유사한 기능도 기대할 수 있겠습니다.

마치며

구체적인 구현 방법에 대해서는 나중에 하나씩 분야별로 추후 정리해보고자 합니다.

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