포스트

홈서버 보안, 막고 있다고 생각했는데 구멍이 있었다

홈서버 보안, 막고 있다고 생각했는데 구멍이 있었다

발단 — 막고 있는데 왜 301이 찍히지?

홈서버에는 이미 UFW 방화벽과 Nginx 보안 설정이 어느 정도 갖춰져 있었다. 그런데 로그를 보다가 이상한 게 눈에 들어왔다.

1
2
4.228.184.28 - - [13/May/2026:13:06:48 +0900] "GET /wp-content/plugins/hellopress/wp_filemanager.php HTTP/1.1" 301 178
4.228.184.28 - - [13/May/2026:13:06:51 +0900] "GET /wp-content/plugins/hellopress/wp_filemanager.php HTTP/1.1" 444 0

분명 wp-content 경로 차단 설정이 있는데, 왜 처음 요청은 301이 찍힐까?

여기서 시작해서 기존 설정의 구멍들을 하나씩 발견하고, 결국 보안 구조 전체를 고도화하게 됐다.


공격 유형 분석

그날 들어온 공격들을 분류하면 크게 4가지였다.

1. 환경변수 파일 탈취 시도

1
GET /.env

.env 파일에는 DB 비밀번호, API 키, JWT 시크릿 등이 담겨있다. 1초 간격으로 2회 반복 — 자동화 봇이다.

2. WordPress 취약점 스캐너

1
GET /wp-content/plugins/hellopress/wp_filemanager.php

WordPress를 쓰지도 않는데 계속 때려본다. 11초 동안 5회 반복. 444로 막히는데도 포기하지 않는다.

3. 디렉토리 브루트포스

1
2
3
GET /aaa9
GET /aab9
GET /aac9 ...

알파벳 순열로 경로를 순차 탐색하며 숨겨진 엔드포인트를 찾는다. 1초 간격, HTTP/HTTPS 동시 시도.

4. 멀티 프로토콜 배너 그래빙

1
2
3
4
5
6
7
admin.$cmd hello     → MongoDB
JDWP-Handshake       → Java 디버거 (원격 코드 실행 가능)
apache-kafka-java    → Kafka
MQTT AAAAA           → IoT 브로커
FeSMB                → SMB
postgres             → PostgreSQL
OPTIONS rtsp://      → IP카메라

80번 포트 하나에 15개 프로토콜을 전부 때려보는 방식이다. 34.140.105.121, 34.14.80.103, 34.78.66.216 — Google Cloud(34.x.x.x) 대역에서 IP를 바꿔가며 같은 툴로 반복했다.

이런 자동화 툴을 멀티 프로토콜 배너 그래버라고 한다. ZGrab2, Masscan 같은 오픈소스를 클라우드 VM에 올려서 전세계 IP를 스캔하는 방식이다. 클라우드 VM을 시간당 몇 센트로 임대해서 돌리고 삭제하니까 추적도 어렵다.


기존 설정의 구멍들

구멍 1 — HTTP 리다이렉트 블록에 보안 필터가 없었다

1
2
3
4
5
6
7
8
9
10
11
# 기존 설정 (문제)
server {
    listen 80;
    server_name totoms.ddns.net;
    return 301 https://$host$request_uri;  # 보안 체크 없이 바로 리다이렉트
}

server {
    listen 443 ssl;
    location ~* wp-content { return 444; }  # 이미 늦음
}

totoms.ddns.net으로 들어오는 요청은 보안 필터를 거치지 않고 HTTPS로 리다이렉트됐다. 그래서 wp-content 요청에 301이 찍혔던 것이다. 공격자 입장에선 서버가 살아있다는 신호가 된다.

구멍 2 — deny-ips.conf가 실제로 적용이 안 되고 있었다

200개가 넘는 IP 차단 목록을 만들어뒀는데, HTTP 리다이렉트 블록에는 include가 빠져있었다.

1
2
3
4
5
6
server {
    listen 80;
    server_name totoms.ddns.net;
    # include /etc/nginx/deny-ips.conf;  -- 이게 없었음
    return 301 https://$host$request_uri;
}

구멍 3 — UFW 룰 순서 문제

1
2
19081  DENY   Anywhere      -- 이게 먼저 매칭돼서 127.0.0.1도 막힘
19081  ALLOW  127.0.0.1     -- 여기까지 안 옴

UFW는 위에서 아래 순서로 적용된다. DENY가 ALLOW보다 위에 있으면 로컬 접속도 차단된다.

구멍 4 — 애플리케이션이 0.0.0.0으로 listen

1
2
3
0.0.0.0:3306  mariadbd
0.0.0.0:3690  svnserve
*:19081~19085 java

UFW로 외부 접근은 막고 있었지만, 애플리케이션 자체가 0.0.0.0(모든 인터페이스)으로 listen하고 있었다. UFW 규칙이 하나라도 빠지거나 비활성화되면 그대로 전세계에 노출되는 구조다.


고도화 — 다층 방어 구조로 개선

1. Nginx — HTTP 블록 선차단 구조로 변경

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server {
    listen 80;
    server_name totoms.ddns.net;

    # 리다이렉트 전에 차단 먼저
    if ($bad_agent) { return 444; }

    include /etc/nginx/deny-ips.conf;

    location ~* (wp-content|wp-admin|\.env|\.php$|\.git) {
        return 444;
    }

    return 301 https://$host$request_uri;
}

2. nginx.conf 보안 강화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
http {
    server_tokens off;                                              # 버전 숨김
    limit_req_zone $binary_remote_addr zone=global:10m rate=20r/m; # rate limit

    map $http_user_agent $bad_agent {
        default     0;
        ""          1;   # UA 없음
        ~*fasthttp  1;   # 고속 봇
        ~*zgrab     1;   # 스캐너
        ~*masscan   1;
        ~*nikto     1;
        ~*sqlmap    1;
    }

    ssl_protocols TLSv1.2 TLSv1.3;  # TLS 1.0/1.1 제거
}

3. UFW 포트 정리 — 순서 교정

1
2
3
4
5
6
7
8
9
10
# ALLOW 먼저, DENY 나중으로 순서 맞추기
sudo ufw allow from 127.0.0.1 to any port 19081
sudo ufw deny 19081

# MariaDB — VPN 대역만 허용
sudo ufw allow from 10.0.0.0/24 to any port 3306
sudo ufw deny 3306

# SVN 외부 차단
sudo ufw deny 3690

4. fail2ban — 반복 요청 자동 차단

수동으로 IP를 추가하는 건 한계가 있다. fail2ban으로 자동화했다.

1
2
3
4
5
6
7
8
9
# /etc/fail2ban/jail.local
[nginx-bruteforce]
enabled  = true
port     = http,https
filter   = nginx-bruteforce
logpath  = /var/log/nginx/access.log
maxretry = 3
findtime = 30
bantime  = 604800   ; 7일 차단
1
2
3
4
5
6
7
8
# /etc/fail2ban/filter.d/nginx-bruteforce.conf
[Definition]
failregex = <HOST> .* "(GET|POST) /(wp-|\.env|\.php|admin|webui|phpmyadmin) HTTP.*" (301|403|444)
            <HOST> .* "JDWP-Handshake" .*
            <HOST> .* ".*fasthttp.*" .*
            <HOST> .* "PROPFIND .* HTTP.*" .*
            <HOST> .* ".*zgrab.*" .*
ignoreregex =

5. iptables — 클라우드 대역 통째로 차단

PG사 콜백 IP가 전부 한국 통신사(KORNET, DANALPAY) 대역임을 whois로 확인 후 진행했다. 서비스에 영향 없는 게 확인됐으니 클라우드 대역을 통째로 막는다.

1
2
3
4
5
6
7
8
9
# Google Cloud (34.x.x.x)
sudo iptables -A INPUT -s 34.0.0.0/8 -j DROP

# Microsoft Azure (20.x.x.x, 4.x.x.x)
sudo iptables -A INPUT -s 20.0.0.0/8 -j DROP
sudo iptables -A INPUT -s 4.0.0.0/8  -j DROP

# AWS (18.x.x.x)
sudo iptables -A INPUT -s 18.0.0.0/8 -j DROP

iptables는 패킷이 Nginx에 도달하기 전 커널 레벨에서 차단한다. access.log에 기록조차 남지 않고, 서버 리소스도 거의 안 쓴다.


중간 사고 — chown -R /usr 실수

보안 작업 중 /usr 하위 디렉토리 소유자가 root가 아닌 걸 발견했다.

1
sudo chown -R root:root /usr  # 이게 문제였다

Linux는 chown 실행 시 보안상 setuid 비트를 자동으로 제거한다. -R 옵션으로 전체에 적용하니 sudo, su, pkexec 전부 동시에 망가졌다.

1
sudo: /usr/bin/sudo must be owned by uid 0 and have the setuid bit set

외부에 있어서 물리 접근도 안 됐다. 다행히 SSH root 로그인이 활성화되어 있어 복구했다.

1
2
3
4
5
ssh root@localhost

chown root:root /usr/bin/sudo && chmod 4755 /usr/bin/sudo
chown root:root /usr/bin/su   && chmod 4755 /usr/bin/su
chown root:root /usr/bin/pkexec && chmod 4755 /usr/bin/pkexec

다음부터는 chown -R 전에 setuid 목록을 먼저 백업해야 한다.

1
2
# 실행 전 백업
sudo find /usr -perm /4000 -o -perm /2000 > /tmp/setuid_backup.txt

결과 확인 — kern.log에서 BLOCKED

설정 완료 후 kern.log를 보니 이런 로그가 찍히기 시작했다.

1
2
3
[BLOCKED] IN=wlp1s0 SRC=20.163.15.97 DST=192.168.50.40 DPT=443 PROTO=TCP SYN
[BLOCKED] IN=wlp1s0 SRC=20.163.15.97 DST=192.168.50.40 DPT=443 PROTO=TCP ACK PSH
[BLOCKED] IN=wlp1s0 SRC=20.163.15.97 DST=192.168.50.40 DPT=443 PROTO=TCP ACK FIN

20.163.15.97은 Microsoft Azure 대역이다. 443 포트로 TCP 세션 전체를 시도했지만 전부 커널에서 DROP됐다. Nginx access.log에는 아무것도 찍히지 않았다.


최종 방어 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
인터넷
  │
  ▼
[iptables]     클라우드 대역(GCP/AWS/Azure) — 커널 레벨 즉시 DROP
  │
  ▼
[UFW]          불필요한 포트 차단 (3306/3690/19081~19085)
  │
  ▼
[fail2ban]     반복 요청 IP 자동 차단 (7일)
  │
  ▼
[Nginx]        악성 경로/UA/rate limit 필터링 — 444 응답
  │
  ▼
[Spring Boot]  실제 서비스

마무리

어느 정도 막고 있다고 생각했는데, 로그를 제대로 들여다보니 구멍이 여러 곳에 있었다. 보안은 “설정했다”가 아니라 “동작하고 있다”를 확인해야 한다.

오늘 작업으로 얻은 교훈:

  1. include 됐는지 nginx -T로 반드시 확인
  2. HTTP 블록에도 보안 필터가 필요하다. 리다이렉트보다 차단이 먼저다.
  3. UFW 룰 순서는 ALLOW가 DENY보다 위에 있어야 한다.
  4. 클라우드 대역은 iptables로 통째로 막는 게 효율적이다.
  5. chown -R은 시스템 디렉토리에 함부로 쓰지 않는다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.