홈서버 보안, 막고 있다고 생각했는데 구멍이 있었다
발단 — 막고 있는데 왜 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] 실제 서비스
마무리
어느 정도 막고 있다고 생각했는데, 로그를 제대로 들여다보니 구멍이 여러 곳에 있었다. 보안은 “설정했다”가 아니라 “동작하고 있다”를 확인해야 한다.
오늘 작업으로 얻은 교훈:
- include 됐는지
nginx -T로 반드시 확인 - HTTP 블록에도 보안 필터가 필요하다. 리다이렉트보다 차단이 먼저다.
- UFW 룰 순서는 ALLOW가 DENY보다 위에 있어야 한다.
- 클라우드 대역은 iptables로 통째로 막는 게 효율적이다.
chown -R은 시스템 디렉토리에 함부로 쓰지 않는다.