SSH 키 인증만 켰는데 비밀번호 로그인이 계속 열려 있었다

이 글은 월 1만 5천 원짜리 VPS에 24시간 AI 비서를 올렸다 시리즈의 다섯 번째 글이다.
앞 편: 에이전트·n8n·워드프레스를 한 서버에 올릴 때 사양 고르기

서버를 받고 제일 먼저 한 일이 SSH를 잠그는 것이었다. 순서대로 했다. 키를 만들고, 서버에 올리고, /etc/ssh/sshd_config에서 비밀번호 인증을 끄고, sshd를 재시작했다.

재시작은 조용히 성공했다. 에러도, 경고도 없었다.

그런데 확인해보니 비밀번호 로그인이 여전히 열려 있었다.

구축 전체에서 가장 오래 헤맨 지점이다. 그리고 이 시리즈에서 처음 만난 진짜 함정이고, 형태가 앞으로 계속 반복된다. 설정은 정상이고, 명령은 성공하고, 동작만 안 한다.


증상 – 고쳤는데 안 고쳐졌다

파일을 열어 값을 바꿨다.

PasswordAuthentication no

저장하고 재시작했다.

sudo systemctl restart ssh

아무 말도 없었다. 리눅스에서 아무 말이 없으면 보통 성공이다.

파일을 다시 열어봐도 값은 분명히 no였다.

$ sudo grep PasswordAuthentication /etc/ssh/sshd_config
PasswordAuthentication no

그런데 sshd가 실제로 어떤 값으로 돌고 있는지 물어보는 명령이 따로 있다.

$ sudo sshd -T | grep passwordauthentication
passwordauthentication yes

-T는 설정 파일을 전부 읽고 최종적으로 확정된 값을 뱉는다. 파일에 뭐라고 적혀 있는지가 아니라 sshd가 실제로 쓰는 값이다.

파일에는 no, 실제로는 yes. 둘이 달랐다.


원인 – 내가 고친 파일이 먼저 읽히지 않았다

PasswordAuthentication이 어디어디 적혀 있는지, 그리고 다른 파일을 끌어오는 줄이 있는지 전부 뒤졌다.

$ sudo grep -rn "PasswordAuthentication\|^Include" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/
/etc/ssh/sshd_config:12:Include /etc/ssh/sshd_config.d/*.conf
/etc/ssh/sshd_config:65:PasswordAuthentication no
/etc/ssh/sshd_config.d/50-cloud-init.conf:1:PasswordAuthentication yes
/etc/ssh/sshd_config.d/60-cloudimg-settings.conf:1:PasswordAuthentication no

같은 설정이 세 군데에 있었다. 12번 줄의 Include는 디렉터리 하나를 통째로 끌어오는 줄이고, 그 디렉터리 안에 클라우드 초기화가 만들어둔 파일 두 개가 이미 들어 있었다. 하나는 yes, 하나는 no.

여기까지는 “아, 이게 내 설정을 덮어썼구나” 싶다. 그런데 실제로는 반대다.
OpenSSH 매뉴얼에 규칙이 이렇게 적혀 있다.

Unless noted otherwise, for each keyword, the first obtained value will be used.
(별도 언급이 없으면, 각 키워드는 처음 얻은 값을 쓴다)

나중 값이 앞의 값을 덮어쓰는 게 아니라, 먼저 나온 값으로 확정되고 그 뒤는 무시된다.
대부분의 설정 파일과 반대다.

Include가 12번 줄, 내가 고친 값이 65번 줄이다. 그러니 .d 안의 파일들이 본문보다 먼저 읽힌다. 그리고 .d 안에서는 50-cloud-init.conf가 가장 앞이다. 그래서 yes가 먼저 확정됐고, 뒤에 나온 no 두 개는 읽히긴 했지만 버려졌다.

60-cloudimg-settings.conf에 no가 들어 있었는데도 소용없었다는 점이 이 규칙을 가장 잘 보여준다. 맞는 값이 있어도 순서에서 지면 무시된다.

먼저 읽은 값이 이긴다. 이 규칙 하나 때문이었다.

Include된 파일들끼리는 이름순으로 읽힌다. 매뉴얼에 glob 패턴이 “lexical order”로 처리된다고 적혀 있다. 그러니까 파일 이름 앞의 숫자가 곧 우선순위다.


해결 – 이름으로 이기게 한다

50-cloud-init.conf를 지우거나 고치는 방법도 있다. 나는 그렇게 안 했다.

클라우드 초기화가 만든 파일이라 OS 업데이트나 재설치 때 되살아날 수 있기 때문이다. 지워도 다시 생기면 원점이고, 그때는 왜 다시 열렸는지 기억도 안 난다.

대신 더 앞 번호의 파일을 새로 만들었다.

sudo tee /etc/ssh/sshd_config.d/00-hardening.conf > /dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
EOF

00으로 시작하니 50보다 먼저 읽힌다. 먼저 읽힌 값이 이기므로, cloud-init 파일이 남아 있든 되살아나든 상관없어진다. 싸워서 이기는 게 아니라 순서로 이기는 방식이다.

재시작은 문법 검사와 묶었다.

sudo sshd -t && sudo systemctl restart ssh

sshd -t는 아무것도 출력하지 않으면 정상이고, 문법이 틀렸으면 에러를 내고 멈춘다. &&로 묶어두면 검사를 통과해야만 재시작이 실행된다. 이걸 건너뛰고 재시작했다가 sshd가 안 뜨면, SSH로 들어가서 고쳐야 하는데 SSH가 죽어 있는 상황이 된다.

그다음 다시 확인한다.

sudo sshd -T | grep passwordauthentication
passwordauthentication no

이제 맞다.


끊기 전에 반드시 할 것

여기서 절대 하면 안 되는 게 지금 쓰던 터미널을 닫는 것이다.

키 인증이 실제로 되는지 확인하기 전에 세션을 끊으면, 설정이 잘못됐을 때 들어갈 방법이 없어진다. 비밀번호는 이미 막아뒀으니까.

그래서 순서가 이렇다.

  1. 기존 창은 열어둔 채로
  2. 새 터미널을 하나 더 열어서 접속해본다
  3. 키로 들어가지는 걸 확인한다
  4. 그다음에 원래 창을 닫는다

새 창에서 안 들어가지면, 아직 살아 있는 원래 창에서 되돌리면 된다. SSH 설정을 바꿀 때마다 이 순서를 지켰고, 덕분에 구축하는 동안 한 번도 잠기지 않았다.

호스팅 업체의 관리 패널에 브라우저 터미널이 있어서 최악의 경우 그쪽으로 들어갈 수는 있다. 다만 그건 비상구로 남겨두고, 평소에는 노트북 터미널에서 SSH로 붙는다. 비상구가 있다는 걸 알면 잠그는 작업이 덜 무섭다. 그래도 비상구를 쓸 일이 없게 하는 게 먼저다.


같은 김에 잠근 것들

SSH를 손보는 김에 나머지도 같이 정리했다. 서버를 받고 처음 한 시간에 다 끝내는 게 낫다. 나중으로 미루면 컨테이너가 올라간 뒤라 건드리기가 껄끄러워진다.

root 로그인 차단과 작업 계정

root로 직접 붙지 않도록 막고, 별도 계정을 만들어 그 계정으로만 들어간다. 자동화 공격은 대부분 root를 노리고 들어오는데, 계정 이름이 다르면 그 시도들이 전부 빗나간다.

위의 00-hardening.conf에 PermitRootLogin no를 넣은 게 그것이다.

방화벽 — 그리고 ufw가 못 막는 것

방화벽을 걸 수 있는 곳이 두 군데 있다. 하나는 관리 패널의 방화벽으로 서버 바깥에서 동작한다. 다른 하나는 서버 안에서 도는 ufw다.

나는 ufw만 썼다. 관리 패널 방화벽에는 규칙을 만들지 않았다. 두 겹으로 걸면 접속이 안 될 때 원인을 두 곳에서 찾아야 하고, 규칙을 잘못 만들면 그 자리에서 서버에 못 들어가게 된다. 하나만 제대로 관리하는 쪽을 택했다.

ufw는 세 개만 열었다.

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable

OpenSSH는 22번 포트를 뜻하는 이름이고, 80과 443은 리버스 프록시가 HTTPS를 처리하려고 쓴다. --force는 “기존 SSH 연결이 끊길 수 있다”는 확인 질문을 건너뛰는 옵션이다. 그래서 SSH를 여는 줄이 반드시 enable보다 먼저 와야 한다. 순서를 바꾸면 경고도 없이 그 자리에서 잠긴다.

여기까지 하면 “이제 22, 80, 443 말고는 다 막혔다”고 생각하기 쉽다. 아니다. 도커 컨테이너가 포트를 외부에 publish하면, 그 포트는 ufw를 그냥 지나간다.

ufw는 도커가 연 포트를 못 막는다.

이유는 이렇다. ufw는 결국 iptables 규칙을 만들어주는 도구인데, 도커도 iptables를 직접 건드린다. 그리고 도커가 넣는 규칙이 ufw 규칙보다 먼저 처리된다. 그래서 docker run -p 5432:5432처럼 포트를 열면, ufw에서 5432를 막아놨어도 밖에서 그대로 접속된다.

ufw 상태를 보면 막혀 있다고 나온다. 실제로는 열려 있다. 이 편 앞부분의 sshd와 정확히 같은 형태다.

이 구성이 안전했던 건 ufw 덕분이 아니었다. 처음부터 외부에 publish한 포트가 없었기 때문이다.

컨테이너외부 노출 방식
리버스 프록시host 네트워크 모드 — 80/443을 직접 쓴다
n8n, 워드프레스프록시 경유 — 포트를 따로 안 연다
데이터베이스 2개내부 네트워크 전용
텔레그램 로컬 API127.0.0.1에만 묶음 — 서버 밖에서 안 보인다

리버스 프록시가 host 네트워크 모드라 80과 443은 도커의 포트 publish를 거치지 않고, 그래서 ufw의 일반 규칙이 그대로 적용된다. 나머지는 전부 프록시 뒤에 있거나 아예 밖으로 안 나간다.

이론상으로는 관리 패널 방화벽이 이 구멍을 막아줄 수 있다. 서버 바깥에 있어서 서버 안의 도커가 손댈 수 없기 때문이다. 다만 규칙을 걸어뒀을 때 얘기다. 나는 걸지 않았다.

그러니 솔직하게 정리하면, 이 서버에서 도커 포트를 막아주는 방화벽은 실질적으로 없다. 믿을 건 “처음부터 포트를 외부에 publish하지 않는다”는 구조 하나다. 그래서 compose 파일에 ports: 항목을 추가하는 건 이 서버에서 가장 조심해야 하는 변경이 됐다. 꼭 열어야 한다면 127.0.0.1:포트:포트처럼 서버 안에서만 보이게 묶는다. 텔레그램 로컬 API가 그렇게 묶여 있다.

나중에 관리 패널 방화벽에도 규칙을 걸게 되면 반대 방향을 기억해야 한다. 그때부터는 두 겹을 다 통과해야 서비스에 닿으니, 새 포트를 열 때 한쪽만 열어두면 접속이 안 된다. 어느 쪽에 막혔는지는 에러 메시지로 구분이 안 간다.

fail2ban

로그를 보고 반복 실패한 IP를 자동으로 차단한다. 키 인증만 열어뒀으면 비밀번호를 찍어보는 시도는 어차피 성공할 수 없지만, 로그가 그런 시도로 가득 차면 정작 봐야 할 게 안 보인다.

스왑 4GB

메모리가 8기가인데 스왑을 4기가 잡았다. 앞 편에서 메모리 부족이 조용히 프로세스를 죽이는 문제를 얘기했는데, 스왑은 그 상황에서 느려지더라도 죽지는 않게 해주는 완충재다.

빨라지려고 넣는 게 아니라 안 죽으려고 넣는 것이다.

시간대

Asia/Seoul로 바꿨다. 기본값이 UTC라 그대로 두면 로그 시각이 9시간 어긋나고, 크론이 도는 시각도 UTC 기준이 된다. 아침 6시에 뉴스를 받으려고 0 6 * * *를 걸어두면 오후 3시에 오는 식이다.

그런데 서버 시간대를 바꾼 것으로 끝이 아니었다. 에이전트를 올린 뒤 대화를 해보니 “오늘”이 하루 밀려 있었다. 컨테이너는 서버와 별개로 자기 시계를 들고 있어서, 서버를 서울로 바꿔도 컨테이너 안은 계속 UTC였다. compose 파일에 이 한 줄을 넣고 다시 띄우고 나서야 맞았다.

environment:
  TZ: "Asia/Seoul"

서버에서 고친 게 컨테이너에는 안 넘어간다.
이 형태는 다음 편에서 한 번 더, 더 아프게 만난다. 어느 쪽이든 크론을 걸기 전에 정리해두는 게 맞다.


이 함정의 형태를 기억해둘 것

한 편 안에서 같은 형태를 두 번 만났다.

적혀 있는 것실제로 도는 것
sshd파일에 noyes로 동작
ufw상태에 “막힘”도커 포트는 열려 있음

형태가 늘 같다.

  • 명령이 성공한다
  • 에러가 없다
  • 그런데 동작이 다르다

이런 종류는 “설정에 뭐라고 적혀 있나”를 봐서는 절대 안 찾아진다. 적힌 값과 도는 값이 다른 게 문제의 본질이기 때문이다.

그래서 확인할 때는 설정이 아니라 결과를 직접 봐야 한다. sshd에는 sshd -T가 있었다. 포트는 서버 밖에서 실제로 접속해보는 게 가장 확실하다. 대부분의 서버 프로그램에 “지금 실제로 뭘로 돌고 있나”를 묻는 방법이 하나씩 있고, 그걸 찾아두는 게 디버깅의 절반이다.

이 시리즈의 나머지 편에서도 이 형태가 계속 나온다.


이 편에서 막힌 것

증상 – sshd_config에서 PasswordAuthentication no로 고치고 재시작했는데 비밀번호 로그인이 계속 열려 있었다. 에러도 경고도 없었다.
원인 – sshd_config 맨 위의 Include가 sshd_config.d/를 먼저 읽는다. 그 안의 50-cloud-init.conf에 yes가 있었고, sshd는 먼저 얻은 값을 쓰기 때문에 나중에 나온 no가 무시됐다.
해결 – 그 파일을 고치는 대신 00-hardening.conf를 새로 만들었다. 이름순으로 먼저 읽히므로 cloud-init 파일이 되살아나도 영향이 없다.
확인 방법 – sudo sshd -T로 최종 확정값을 조회. 파일 내용이 아니라 실제로 도는 값을 봐야 한다.
같은 형태 하나 더 – ufw로 22·80·443만 열어도 도커가 publish한 포트는 ufw를 우회한다. 관리 패널 방화벽이 막을 수 있지만 규칙을 걸지 않았다. 이 구성이 안전한 건 외부에 publish한 포트가 없어서다. 그래서 ports: 추가가 이 서버에서 가장 조심해야 하는 변경이 됐다.
다음 편 – 서버가 준비됐으니 창구를 연다. 텔레그램 봇을 그룹에 넣었는데 아무 대답을 안 했다. 원인이 두 개였다.


참고

  • OpenSSH sshd_config 매뉴얼 — https://man7.org/linux/man-pages/man5/sshd_config.5.html
  • Baeldung, Docker 컨테이너 포트가 UFW 규칙을 무시하는 이유 — https://www.baeldung.com/linux/docker-container-published-port-ignoring-ufw-rules
  • chaifeng/ufw-docker, 도커와 UFW 보안 결함 해결 방법 — https://github.com/chaifeng/ufw-docker

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다