JWT_SECRET이 유출되면 서비스는 끝난다

"서명 키 하나 유출된 것뿐인데 뭐가 그렇게 심각한가요?"
만약 이렇게 생각하신다면 이 글을 꼭 읽어보세요.
JWT_SECRET은 단순한 설정값이 아니라, 당신 서비스의 마스터 키입니다.
유출 시 공격자가 어떤 일까지 할 수 있는지, 실제 공격 시나리오로 보여드립니다.


🚨 문제점 — JWT_SECRET의 진짜 역할

Spring Boot에서 JWT 기반 인증을 구현할 때 흔히 이런 코드를 작성합니다:

@Component
public class JWTUtil {

    private SecretKey secretKey;

    public JWTUtil(@Value("${jwt.secret}") String secret) {
        this.secretKey = new SecretKeySpec(
            secret.getBytes(StandardCharsets.UTF_8), 
            Jwts.SIG.HS256.key().build().getAlgorithm()
        );
    }

    public String createJwt(String username, String role) {
        return Jwts.builder()
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + 86400000L))
                .signWith(secretKey)   // ← 이 키로 서명
                .compact();
    }

    public String getUsername(String token) {
        return Jwts.parser().verifyWith(secretKey).build()
                .parseSignedClaims(token).getPayload()
                .get("username", String.class);
    }
}

여기서 jwt.secretJWT_SECRET입니다. 대부분 환경변수나 application.yml에 저장하죠.

JWT_SECRET은 "신분증 발급 도장"입니다

JWT는 "서명된 신분증"입니다. 서버는 사용자가 제출한 토큰의 서명을 JWT_SECRET으로 검증해서 "내가 발급한 진짜 토큰"인지 판단합니다.

즉 이 키의 역할은:

  • 🖊️ 토큰 발급 시 서명: "이 토큰은 내가 만든 것이다"라고 도장 찍기
  • 🔍 토큰 검증 시 대조: 제출된 토큰의 도장이 내 도장과 같은지 확인
  • HS256(대칭키) 방식이면 서명 키 = 검증 키*입니다. 이 키 하나만 유출되면 공격자도 진짜와 구별 불가능한 토큰을 무제한으로 발급할 수 있습니다.

유출 경로는 생각보다 다양합니다

제가 직접 겪거나 뉴스에서 본 JWT_SECRET 유출 경로들:

  • 📝 Git 실수 커밋application.yml에 하드코딩한 채 푸시. git 히스토리는 영원히 남습니다.
  • 🔍 환경변수 덤프.pem 키 탈취 공격자가 /proc/{PID}/environ 읽기
  • 📋 로그 파일log.info("JWT 설정: {}", jwtSecret) 같은 실수
  • 🏃 CI/CD 로그 노출 — GitHub Actions 로그에서 mask 처리 누락
  • 🌐 Actuator 엔드포인트 노출/actuator/env가 열려 있으면 환경변수 전체가 외부에 노출
  • 한 번 유출되면 "JWT_SECRET을 바꾸지 않는 한 영원히 유효"*하다는 점이 가장 무섭습니다. 그리고 대부분의 팀은 유출 사실 자체를 인지하지 못합니다.

💀 실제 해킹 시나리오 — JWT_SECRET이 유출됐을 때

제가 운영 중인 서비스를 예로 들어 공격자 관점에서 전체 공격 사슬을 재구성해보겠습니다. 제 서비스는 간단히 말하면 외부 API 키를 사용자별로 저장하고, 그 키로 자동화 작업을 수행하는 백엔드입니다.

공격 사슬 전체 그림

[Stage 0] JWT_SECRET 획득
    ↓
[Stage 1] 유효한 username 수집
    ↓
[Stage 2] 임의 사용자의 JWT 위조
    ↓
[Stage 3] 관리자 권한 탈취
    ↓
[Stage 4] 타겟 사용자의 민감 데이터 접근
    ↓
[Stage 5] 자금/자산 유출
    ↓
[Stage 6] 흔적 지우기

Stage 0 — JWT_SECRET 획득

공격자 김해커 씨가 어떤 경로로든 JWT_SECRET을 손에 넣었다고 가정합시다. GitHub에서 우연히 발견했을 수도 있고, 내부자에게서 구매했을 수도 있습니다. 이 시점에서 공격자는 서비스의 모든 사용자로 행세할 수 있는 능력을 갖춘 셈입니다.

Stage 1 — 정찰: 유효한 username 수집

공격자는 JWT를 위조할 수 있지만 "누구 행세를 할지" 정해야 합니다. 타겟의 username을 알아내는 경로는 여러 가지가 있습니다:

경로 A — 회원가입 응답 분석

# 중복 체크 허용 여부 확인
curl -X POST https://target.com/api/user \
     -H "Content-Type: application/json" \
     -d '{"username":"admin","password":"test","authCode":"test"}'
# → "이미 사용중인 아이디" 응답이 나오면 admin 계정 존재 확인

경로 B — 로그인 응답 시간 차이

UserNotFoundException과 비밀번호 불일치의 응답 시간이 미묘하게 다르면, 타이밍 공격으로 username 존재 여부 판별 가능.

경로 C — OSINT

타겟 기업의 개발자 GitHub/LinkedIn/블로그에서 자주 쓰는 username 유추. 보통 admin, {회사명}_admin, 창업자 실명, 개발자 실명 등이 나옵니다.

Stage 2 — JWT 위조

secret과 username이 있으면 위조는 Python 몇 줄이면 됩니다:

import jwt
import time

SECRET = "유출된_JWT_SECRET_값"

# 관리자 권한 토큰 위조
payload = {
    "username": "admin",
    "role": "ADMIN",
    "iat": int(time.time()),
    "exp": int(time.time()) + 86400
}

forged_token = jwt.encode(payload, SECRET, algorithm="HS256")
print(forged_token)
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vy...

서버는 이 토큰을 진짜와 구별할 방법이 없습니다. 서명 검증이 통과되니까요.

# 위조 토큰으로 즉시 접근
curl -b "Authorization=eyJhbGc..." https://target.com/user
# → admin의 사용자 페이지가 정상적으로 반환됨

Stage 3 — 권한 확장

많은 서비스에서 JWT의 role claim을 그대로 권한 판단에 쓰기 때문에, 공격자는 JWT에 role: "ADMIN"을 넣어 즉시 관리자 행세가 가능합니다.

잘 만들어진 서비스는 JWT의 role을 신뢰하지 않고 DB에서 다시 읽어오는데, 그마저도 공격자가 username을 "admin"으로 위조하면 DB에서 실제 관리자 정보를 로드해버리므로 무력화됩니다.

Stage 4 — 타겟 사용자의 민감 데이터 접근

이제 공격자는 누구든 원하는 사용자로 로그인 가능합니다. 특정 타겟의 JWT를 만들어:

# 자산이 많은 타겟 사용자로 위장
victim_token = jwt.encode({
    "username": "victim_user",
    "role": "USER",
    "exp": int(time.time()) + 3600
}, SECRET, algorithm="HS256")
# 피해자의 계좌 잔고 조회
curl -b "Authorization=$victim_token" \
     https://target.com/api/exchange/accounts
# → 피해자의 전체 자산 현황이 반환됨

피해자의 API 키는 DB에 AES-256-GCM으로 암호화되어 있습니다. 훌륭한 방어책이지만, 공격자는 키 원문을 알 필요가 없습니다. 피해자로 로그인된 상태이므로 서버가 알아서 복호화해서 API를 호출해주니까요:

# 피해자 명의로 주문 실행
curl -X POST -b "Authorization=$victim_token" \
     -d 'market=KRW-BTC&side=ask&order_type=market&volume=0.5' \
     https://target.com/api/exchange/orders
# → 피해자의 BTC가 시장가로 매도됨

Stage 5 — API 키 교체 (더 교활한 변형)

공격자가 즉시 대규모 거래를 일으키면 피해자가 눈치챕니다. 더 스마트한 공격은:

# 피해자 계정의 API 키를 공격자 것으로 교체
curl -X POST -b "Authorization=$victim_token" \
     -H "Content-Type: application/json" \
     -d '{"exchangeType":"BITHUMB","apiKey":"공격자_KEY","apiSecretKey":"공격자_SECRET"}' \
     https://target.com/api/exchange/key

이제 피해자의 서비스가 공격자의 외부 계정에 대고 작업을 합니다. 피해자는 자기 봇이 정상 돌고 있다고 믿지만, 실제 수익은 공격자 쪽으로 쌓입니다. 며칠, 몇 주 조용히 털어갈 수 있습니다.

Stage 6 — 흔적 지우기 (JWT의 치명적 약점)

일반적인 세션 방식이면 DB에 "누가 언제 로그인했다"는 기록이 남습니다. 그래서 침해 후 포렌식이 가능하죠.

JWT는 stateless — 서버가 로그인 기록을 따로 남기지 않습니다. 공격자가 언제 어떤 토큰을 썼는지 추적할 방법이 없습니다. 로그에는 "정상적인 API 호출"로만 찍힐 뿐입니다.

왜 이게 결정적인가?

다른 웹 취약점들(CSRF, XSS, SQL Injection 등)은 피해자의 행동이나 특정 입력이 있어야 성립합니다. 하지만 JWT_SECRET 유출은:

  • 피해자와 상호작용이 전혀 필요 없음 — 공격자 혼자서 완결 가능
  • 대상을 고를 수 있음 — 자산이 많은 사용자만 선별 공격
  • 동시에 모든 계정 탈취 가능 — 한 명이 아니라 전체 사용자가 타겟
  • 탐지가 사실상 불가능 — 정상 요청과 구별 안 됨
    한 마디로 "서비스의 루트 권한을 훔쳐간 것"과 같은 수준의 사고입니다.

마치며

JWT_SECRET은 단순한 설정값이 아닙니다. 서비스 전체의 신뢰를 지탱하는 루트 키입니다. 이 키 하나의 유출이 수만 명의 사용자 자산을 위협할 수 있습니다.

많은 개발자들이 JWT를 "편리한 stateless 인증" 정도로만 인식하지만, 그 편리함의 뒷면에는 "서명 키가 곧 모든 권한"이라는 무거운 책임이 있습니다. 로그인 기록을 남기지 않는 특성 때문에 사고 발생 시 추적도 어렵습니다.

이 글이 말하고 싶은 핵심은 두 가지입니다:

  1. JWT_SECRET은 절대 환경변수에 평문으로 두지 마세요. Parameter Store나 Secrets Manager로 옮기세요.
  2. 유출됐다고 의심되면 즉시 로테이션하세요. "아직 공격이 없으니 괜찮겠지"라는 판단이 가장 위험합니다.
    보안은 한 번 뚫리면 되돌릴 수 없습니다. 특히 돈과 자산이 걸린 서비스라면 더더욱 그렇습니다. 오늘 30분만 투자해서 시크릿 관리를 점검해보시길 강력히 권합니다.

 

🏷️ 태그: #JWT #보안 #SpringBoot #AWS #ParameterStore #백엔드 #인증 #DevSecOps #시크릿관리 #해킹방어

 

SSH Google Authenticator 2FA 적용 후 GitHub Actions 배포가 막혔을 때 — 전용 배포 사용자로 해결하기

보안을 강화했더니 CI/CD 파이프라인이 망가졌습니다.
ssh: handshake failed: unable to authenticate 에러를 마주한 개발자라면 이 글이 도움이 될 겁니다.
보안도 지키고 자동 배포도 유지하는 현실적인 해결법을 정리했습니다.


🚨 문제점 — 2FA가 자동화를 막아버렸다

지난 글에서 EC2 서버에 Google Authenticator 기반 SSH 2차 인증을 적용했습니다. .pem 키가 유출되어도 OTP 없이는 접속 불가능한, 아주 튼튼한 방어막이 생겼죠.

그런데 다음날 아침, 코드를 푸시하자 GitHub Actions에서 이런 에러가 뜨기 시작했습니다:

2026/04/16 12:24:02 error copy file to dest: ***, error message: 
ssh: handshake failed: ssh: unable to authenticate, 
attempted methods [none publickey], no supported methods remain
drone-scp error: error copy file to dest: ***, error message: 
ssh: handshake failed: ssh: unable to authenticate, 
attempted methods [none publickey], no supported methods remain

배포 파이프라인이 완전히 막혔습니다.

왜 이런 일이 발생했을까?

지난 글에서 sshd_config에 아래 설정을 넣었습니다:

AuthenticationMethods publickey,keyboard-interactive

이 설정은 "모든 SSH 접속에 .pem 키 AND OTP를 요구"합니다. 쉼표가 AND의 의미라 둘 다 통과해야 접속이 허용되죠.

사람이 직접 접속할 때는 문제 없습니다. 핸드폰 꺼내서 OTP 6자리 입력하면 되니까요. 하지만 GitHub Actions는 사람이 아닙니다. 자동화된 스크립트는:

  • 핸드폰을 가지고 있지 않고
  • OTP 앱을 열 수 없고
  • 6자리 코드를 입력할 방법이 없습니다
    에러 메시지를 해석하면 이해가 쉽습니다:
  • attempted methods [none publickey]: GitHub Actions가 공개키로만 시도함
  • no supported methods remain: 서버가 keyboard-interactive(OTP 단계)도 요구하는데 GitHub Actions가 그걸 처리할 수 없어서 포기
  • 보안을 올렸더니 자동화가 끊긴 고전적인 딜레마*입니다.

 

 

🛡️ 해결 방법 — 전용 배포 사용자 + 제한된 권한

올바른 해결책의 핵심 아이디어는:

"사람용 계정은 강한 2FA를 유지하고, 자동화용 계정은 제한된 권한으로만 동작하게 한다"

즉, ubuntu 같은 일반 사용자는 2FA 필수 유지, 새로 만든 deploy 전용 사용자만 .pem 키로 통과시키되 그 사용자가 할 수 있는 일을 배포 작업에만 제한하는 것입니다.

[ubuntu 계정]   — 개발자 SSH 접속용 — .pem + OTP 필수 (기존 유지)
[deploy 계정]   — GitHub Actions 전용 — .pem만 허용, 하지만 권한은 배포에만 한정

이렇게 하면:

  • GitHub Actions는 OTP 없이 배포 가능 ✅
  • 사람의 접속은 여전히 2FA 보호 ✅
  • deploy 키가 유출되어도 배포 작업 외의 침해는 불가능 ✅Step 1 — 배포 전용 사용자 생성
# 비밀번호 없이 deploy 사용자 생성
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh

--disabled-password 옵션으로 비밀번호 로그인 자체를 차단합니다. 이 사용자는 SSH 키로만 접속 가능합니다.

Step 2 — GitHub Actions 전용 SSH 키 생성

기존 .pem 키와 분리된 새 키를 만드는 게 중요합니다. 한 키가 여러 용도에 쓰이면 유출 시 피해가 커지니까요.

로컬 PC에서:

ssh-keygen -t ed25519 -f deploy_key -C "github-actions-deploy" -N ""
  • -t ed25519: 최신 타원곡선 알고리즘 (RSA보다 짧고 안전)
  • -N "": passphrase 없음 (자동화용이라 필요)
    실행하면 두 파일이 생성됩니다:
  • deploy_key — 개인키 (GitHub Secrets에 저장할 것)
  • deploy_key.pub — 공개키 (서버에 등록할 것)Step 3 — 공개키를 서버에 등록
# 서버에서
sudo nano /home/deploy/.ssh/authorized_keys

deploy_key.pub 내용을 복사해서 붙여넣습니다. 저장 후 권한 설정:

sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh

권한이 중요합니다. SSH는 authorized_keys의 권한이 너무 열려 있으면 보안상 키를 무시해버립니다. 600과 소유자 deploy가 정확해야 합니다.

Step 4 — PAM에서 deploy 사용자만 OTP 예외 처리

sudo nano /etc/pam.d/sshd

맨 위의 설정을 다음과 같이 수정합니다:

# 기존 (전체 사용자 OTP 요구)
# auth required pam_google_authenticator.so nullok

# 변경 — deploy 사용자는 OTP 건너뛰기
auth [success=1 default=ignore] pam_succeed_if.so user = deploy
auth required pam_google_authenticator.so nullok

pam_succeed_if.so"현재 사용자가 deploy라면 다음 한 줄을 건너뛰어라"라는 의미입니다. 즉 deploy 사용자만 OTP 단계를 생략하게 됩니다.

Step 5 — SSH 설정에서 deploy 사용자만 인증 방식 완화

sudo nano /etc/ssh/sshd_config.d/99-2fa.conf

내용:

# 기본: 모두 publickey + OTP
UsePAM yes
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
PasswordAuthentication no
PermitRootLogin no

# deploy 사용자만 publickey만으로 통과
Match User deploy
    AuthenticationMethods publickey
    PasswordAuthentication no

주의: Match User 블록은 파일의 가장 마지막에 있어야 합니다. 그 아래에 다른 전역 설정이 또 있으면 그것까지 deploy 전용 설정으로 잡아먹혀 버립니다.

Step 6 — deploy 사용자의 권한 최소화 ⭐ 가장 중요

여기가 이 방법의 핵심입니다. deploy 사용자가 접속은 가능하지만, 배포 작업 외에는 아무것도 못 하게 만들어야 합니다.

먼저 배포 대상 디렉터리의 소유권을 deploy에게 부여:

sudo chown -R deploy:deploy /opt/app

그리고 앱 재시작에만 필요한 명령을 sudo 없이 실행할 수 있게 합니다. 개별 명령마다 sudoers에 등록하는 건 문법 에러도 자주 나고 관리도 힘드니, 배포용 스크립트 하나로 묶는 방식이 깔끔합니다.

배포 스크립트 작성:

sudo nano /opt/app/restart-app.sh

내용:

#!/bin/bash
set -e

echo ">>> 기존 프로세스 종료"
PID=$(/usr/bin/lsof -t -i:8080 || true)
if [ -n "$PID" ]; then
    /bin/kill -15 $PID
    sleep 5
    if /bin/kill -0 $PID 2>/dev/null; then
        /bin/kill -9 $PID
    fi
    sleep 2
fi

echo ">>> 애플리케이션 재시작"
/bin/systemctl restart myapp
/bin/systemctl status myapp --no-pager

소유자를 root로 고정하는 것이 중요합니다 (deploy가 수정하면 권한 탈취 가능):

sudo chmod 755 /opt/app/restart-app.sh
sudo chown root:root /opt/app/restart-app.sh

그리고 이 스크립트 하나만 sudo 예외 처리:

sudo visudo -f /etc/sudoers.d/deploy

내용:

deploy ALL=(ALL) NOPASSWD: /opt/app/restart-app.sh

visudo는 저장 시 문법 검사를 자동으로 해줍니다. 에러가 나면 저장을 거부해서 시스템이 망가지는 걸 방지합니다.

Step 7 — 설정 검증

# SSH 설정 문법 검사
sudo sshd -t

# deploy 사용자에게 어떤 인증 방식이 요구되는지 확인
sudo sshd -T -C user=deploy | grep -i authenticationmethods
# → "authenticationmethods publickey" 가 나와야 정상

# ubuntu 사용자는 여전히 2FA 요구하는지 확인
sudo sshd -T -C user=ubuntu | grep -i authenticationmethods
# → "authenticationmethods publickey,keyboard-interactive" 가 나와야 정상

두 사용자의 인증 요구사항이 다르게 나오면 설정이 제대로 적용된 겁니다.

Step 8 — SSH 재시작 및 로컬 테스트

⚠️ 반드시 기존 SSH 세션을 열어둔 채로 재시작하세요.

sudo systemctl restart ssh

새 터미널에서 deploy 사용자로 로컬 테스트:

ssh -i deploy_key deploy@{서버주소}

OTP 입력 없이 바로 deploy 쉘로 들어가면 성공입니다. 이걸 확인해야 GitHub Actions도 작동합니다.

만약 막히면 -v 옵션으로 디버깅:

ssh -i deploy_key -v deploy@{서버주소}

Step 9 — GitHub Secrets 교체

리포지토리의 Settings → Secrets and variables → Actions 에서:

Secret 이름
EC2_USER deploy (기존: ubuntu)
EC2_SSH_PRIVATE_KEY deploy_key 파일 전체 내용

개인키 복사 시 주의사항:

  • -----BEGIN OPENSSH PRIVATE KEY----- 부터
  • -----END OPENSSH PRIVATE KEY----- 까지
  • 모든 줄바꿈 유지한 채 전체 복사Step 10 — 배포 스크립트 수정

deploy.sh (혹은 .github/workflows/*.yml) 에서 재시작 부분을 수정:

# JAR 파일 교체 (deploy가 /opt/app 소유자라 sudo 불필요)
cp /tmp/app.jar /opt/app/app.jar

# 애플리케이션 재시작 (sudo로 스크립트 실행)
sudo /opt/app/restart-app.sh

그리고 로컬의 deploy_key 파일은 GitHub Secrets 등록 후 즉시 삭제하세요. 어디에도 남겨두지 마세요.

 

 

⚠️ 주의사항 — 꼭 챙길 것들

1. 설정 변경 전 기존 세션 유지

SSH 설정을 잘못 저장하면 새 접속이 막힙니다. 작업 중인 터미널은 절대 닫지 말고, 새 터미널로 검증한 뒤에 닫으세요.

2. Match User 블록의 위치

sshd_config에서 Match 블록 이후의 설정은 모두 해당 조건에 속해버립니다. 반드시 파일의 맨 마지막에 배치하세요.

3. 배포 스크립트 소유자는 root

/opt/app/restart-app.sh의 소유자가 deploy면 공격자가 스크립트 내용을 조작해서 root 권한으로 임의 명령을 실행할 수 있습니다. 반드시 root:root 소유여야 합니다.

ls -la /opt/app/restart-app.sh
# -rwxr-xr-x 1 root root ... restart-app.sh  ← 이렇게 나와야 함

4. deploy 키는 한 번만 로컬에 존재

deploy_key를 GitHub Secrets에 등록한 후 로컬 파일은 즉시 삭제하세요. 나중에 다시 필요하면 새로 생성하는 게 낫습니다. 키 복사본이 여러 곳에 남을수록 유출 위험이 커집니다.

 

📝 최종 정리

적용 전후 변화

항목 적용 전 적용 후
SSH 2FA 전체 사용자 적용, CI/CD 막힘 사람은 2FA, 자동화는 제한 권한
GitHub Actions 배포 ❌ 실패 ✅ 정상
개발자 접속 보안 ✅ 2FA 보호 ✅ 2FA 보호 (변화 없음)
배포 키 유출 시 피해 (기존: 전체 서버 침해) 앱 재시작 범위로 제한
최소 권한 원칙

 

 

🏷️ 태그: #AWS #EC2 #SSH #GitHubActions #CICD #2FA #보안 #DevOps #최소권한원칙 #PAM #sudoers

EC2 서버에 Google Authenticator로 SSH 2차 인증 구축하기 — .pem 키 탈취 방어 실전

AWS EC2 운영 중 .pem 키가 유출되면 서버에 그대로 뚫린다는 사실, 알고 계셨나요?
이 글에서는 실제로 제가 운영 중인 암호화폐 거래봇 서버에 Google Authenticator 기반 2차 인증을 적용한 전 과정을 정리합니다.
문제 인식 → 실제 해킹 시나리오 → 해결 방법 → 검증까지 단계별로 다룹니다.


🚨 문제점 — .pem 키 단일 인증의 한계

AWS EC2의 기본 SSH 접속 방식은 공개키 인증 하나만으로 이루어집니다. .pem 파일만 있으면 누구나 서버에 접속할 수 있다는 뜻이죠.

현재 제 서버 구조의 취약점

제가 운영 중인 서비스는 Spring Boot 기반 백엔드가 EC2에서 돌아가고, 배포 시 환경변수로 중요 설정값(DB 접속정보, API 인증키, 서명 키 등)을 주입합니다. 문제는 이 환경변수들이 실행 중인 프로세스의 메모리 공간에 평문으로 존재한다는 점입니다.

만약 공격자가 .pem 키를 탈취하면:

# 1. SSH 접속
ssh -i stolen-key.pem ubuntu@{서버주소}

# 2. 애플리케이션 PID 확인
ps aux | grep java

# 3. 프로세스 환경변수 덤프
sudo cat /proc/{PID}/environ | tr '\0' '\n'

이 세 줄이면 서비스의 모든 민감 정보가 평문으로 노출됩니다. DB 비밀번호부터 외부 서비스 API 키까지 전부요.

.pem 키는 생각보다 쉽게 유출됩니다

  • 💾 개발자 노트북 분실/도난
  • 🔓 .pem 파일을 깜빡하고 Git 저장소에 커밋
  • 📧 이메일이나 메신저로 팀원과 키 공유
  • ☁️ 클라우드 드라이브 동기화 폴더에 보관
  • 👤 퇴사자의 로컬 백업에 남아있는 키
    그래서 .pem 키 유출을 "가능한 시나리오"가 아니라 "언젠가 발생할 사건"으로 가정하고 방어선을 추가해야 합니다.

💀 실제 해킹 시나리오 — 한 줄씩 따라가 보기

실제로 제 서비스가 .pem 키 유출로 공격받는다면 어떤 일이 벌어질지, 공격자 관점에서 재구성해보겠습니다.

Stage 0 — .pem 키 획득

공격자 김해커 씨가 팀원 A의 노트북을 카페에서 잠시 훔쳐봤거나, A가 실수로 GitHub에 올린 .pem 키를 자동 스캐너로 수집했다고 가정합시다. 이 시점에서 공격자는 이미 정당한 사용자와 구별 불가능한 접근권한을 손에 넣었습니다.

Stage 1 — SSH 접속 성공

$ ssh -i stolen.pem ubuntu@ec2-xx-xx-xx-xx.ap-northeast-2.compute.amazonaws.com
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.14.0-aws aarch64)

ubuntu@ip-xxx-xx-xx-xx:~$ 

추가 인증 없이 바로 쉘 프롬프트가 뜹니다. 현재 구조에서는 이 순간이 사실상 게임 오버입니다.

Stage 2 — 실행 중인 프로세스 확인

ubuntu@ip-xxx:~$ ps aux | grep java
ubuntu    12345  2.1 15.3 {...} java -jar -Dserver.port=8080 /opt/app/app.jar

Spring Boot 프로세스의 PID를 찾아냈습니다.

Stage 3 — 환경변수 덤프로 시크릿 탈취

ubuntu@ip-xxx:~$ sudo cat /proc/12345/environ | tr '\0' '\n'
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
DB_USERNAME=admin
DB_PASSWORD=MyStr0ngP@ssw0rd!
JWT_SECRET=my-super-secret-signing-key-do-not-share
EXTERNAL_API_KEY=sk-live-xxxxxxxxxxxxxxxx
EXTERNAL_API_SECRET=xxxxxxxxxxxxxxxxxxxxxx
...

모든 민감 정보가 평문으로 덤프됩니다. 이제 공격자는:

Stage 4 — 2차 피해 확산

  • DB 직접 접속: DB_PASSWORD로 데이터베이스에 접속해서 사용자 데이터 전체 유출
  • 서비스 사칭: JWT_SECRET으로 임의 사용자의 토큰을 위조 → 관리자 권한으로 로그인
  • 외부 API 남용: EXTERNAL_API_KEY로 유료 서비스 크레딧 소진, 비용 청구서 폭발
  • 흔적 삭제: sudo 권한으로 로그 파일 조작 → 침해 사실 은폐Stage 5 — 완전 침해 (최악의 경우)

공격자가 얻은 시크릿을 바탕으로 정상적인 사용자로 위장하여 지속 공격을 수행합니다. .pem 키만 있으면 서버에 자유롭게 드나들 수 있으니, 백도어 설치 → 장기 잠복 → 적절한 타이밍에 대규모 피해 실행의 수순이죠.

이 모든 과정이 몇 분 안에 끝납니다. 그리고 서버 로그에는 "정상적인 ubuntu 사용자의 접속"으로만 찍힙니다.


🛡️ 해결 방법 — Google Authenticator로 2차 방어선 구축

핵심 아이디어는 간단합니다. .pem 키(Something you HAVE) 에 더해, OTP 코드(Something you KNOW at this moment) 라는 완전히 다른 차원의 인증 요소를 요구하는 것입니다.

기존: .pem 키 통과 → 쉘 접속 ✅
개선: .pem 키 통과 → OTP 6자리 통과 → 쉘 접속 ✅

공격자가 .pem 키를 얻어도 제 핸드폰의 OTP 앱이 없으면 접속이 불가능해집니다.

Step 1 — Google Authenticator PAM 모듈 설치

Ubuntu 기준:

sudo apt update && sudo apt install -y libpam-google-authenticator

Amazon Linux 2 / 2023이라면:

sudo dnf install -y google-authenticator

Step 2 — 사용자 계정에서 OTP 시크릿 생성

SSH로 접속한 상태에서 본인 사용자 계정으로 실행합니다:

google-authenticator

대화형 질문이 7개 정도 나옵니다. 권장 답변과 그 이유를 정리하면:

질문 이유
Time-based tokens? y 대부분의 OTP 앱이 표준으로 지원하는 TOTP 방식
Update .google_authenticator? y 설정을 파일에 저장해서 재부팅 후에도 유지
Disallow multiple uses? y 같은 코드 재사용 금지 → 네트워크 도청 공격(MITM) 방어
Increase window to 4:00? n 기본 1:30이면 충분. 창이 넓을수록 공격 기회 증가
Rate-limit (3 tries/30s)? y 브루트포스 공격 차단

터미널에 표시되는 QR 코드를 스마트폰의 Google Authenticator / Authy / 1Password 앱으로 스캔하세요. 그리고 Scratch Code(비상 복구 코드) 5개는 반드시 비밀번호 관리자나 안전한 오프라인 장소에 보관합니다. 핸드폰을 잃어버리면 이게 유일한 복구 수단입니다.

Step 3 — PAM 설정 수정

sudo nano /etc/pam.d/sshd

파일 맨 위에 추가:

auth required pam_google_authenticator.so nullok

그리고 같은 파일에서 아래 줄을 찾아 주석 처리합니다 (Ubuntu 기준):

#@include common-auth

common-auth가 살아있으면 비밀번호 인증이 먼저 통과되어 OTP 단계까지 가지 않을 수 있습니다.

nullok 옵션은 .google_authenticator 파일이 없는 사용자는 OTP 없이 통과시킵니다. 모든 사용자가 설정을 마쳤다면 이 옵션을 제거해 강제하는 것이 더 안전합니다.

Step 4 — SSH 설정 수정

sudo nano /etc/ssh/sshd_config

또는 Ubuntu라면 보통 아래 방식이 더 안전합니다 (sshd_config.d/ 디렉터리의 다른 설정 파일과 충돌 방지):

sudo nano /etc/ssh/sshd_config.d/99-2fa.conf

아래 내용 추가:

UsePAM yes
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
PasswordAuthentication no
PermitRootLogin no

핵심은 AuthenticationMethods publickey,keyboard-interactive입니다. 쉼표가 AND의 의미이므로 "공개키 통과 AND OTP 통과" 둘 다 요구하게 됩니다.

Step 5 — 문법 검사 후 SSH 재시작

⚠️ 설정을 잘못 저장하고 재시작하면 .pem 키로도 접속이 불가능해집니다. 반드시 문법 검사부터 하세요.

# 문법 검사 (아무 출력 없으면 OK)
sudo sshd -t

# 적용된 설정 확인
sudo sshd -T | grep -iE "authenticationmethods|kbdinter|passwordauth"

기대 출력:

passwordauthentication no
kbdinteractiveauthentication yes
authenticationmethods publickey,keyboard-interactive

문제 없으면 재시작:

# Ubuntu
sudo systemctl restart ssh

# Amazon Linux 등
sudo systemctl restart sshd

Step 6 — 반드시 현재 세션을 유지한 채 새 터미널에서 테스트

기존 SSH 세션은 절대 닫지 마세요. 만약 설정에 문제가 있어서 새 접속이 막히면, 기존 세션에서만 롤백이 가능합니다.

새 터미널 창에서:

ssh -i your-key.pem ubuntu@{서버주소}

정상 흐름:

Authenticated with partial success.
Verification code: ______   ← 여기서 OTP 앱의 6자리 입력
Last login: ...
ubuntu@ip-xxx:~$

Verification code: 프롬프트가 뜨고, 올바른 OTP를 입력했을 때 쉘이 열리면 성공입니다. 이걸 확인한 후에야 기존 세션을 닫으세요.


🔍 해킹 시나리오 재검증 — 같은 공격을 다시 시도하면?

2차 인증 적용 후 공격자 입장에서 같은 공격을 시도해봅시다:

$ ssh -i stolen.pem ubuntu@ec2-xx-xx-xx-xx.ap-northeast-2.compute.amazonaws.com
Authenticated with partial success.
Verification code: 

공격자는 여기서 멈춥니다. OTP 6자리를 모르니까요. 100만 가지 경우의 수를 시도해도:

  • 30초마다 코드가 바뀌므로 브루트포스 불가능
  • Rate limit(30초당 3회)로 자동화 공격 차단
  • 코드 재사용 금지로 가로챈 코드 재사용 불가
    공격자가 접속하려면 제 핸드폰까지 물리적으로 탈취해야 합니다. 난이도가 차원이 다르게 올라가죠.

방어 효과 비교

공격자가 가진 것 이전 이후
.pem 키만 ❌ 즉시 침해 ✅ OTP 단계에서 차단
OTP만 (코드 유출) ✅ publickey 단계에서 차단
.pem + OTP 앱 핸드폰 ✅ 접속 가능 (정상 사용자)

⚠️ 주의사항 — 꼭 챙겨야 할 것들

1. Scratch Code 백업은 생명줄입니다

google-authenticator 실행 시 출력된 8자리 복구 코드 5개를 지금 당장 안전한 곳에 저장하세요. 핸드폰 분실/초기화/OTP 앱 삭제 시 이것 없이는 영영 접속 불가입니다.

저장 위치 추천:

  • 1Password, Bitwarden 등 비밀번호 관리자의 Secure Note
  • 물리적 종이로 안전한 서랍/금고
    권장하지 않는 위치:
  • 이메일 (유출 시 위험)
  • 평문 파일 (해킹 시 같이 노출)
  • 클라우드 드라이브 (동기화 사고 가능)2. 백업 접속 경로 확보

3. 서버 시간 동기화 확인

TOTP는 서버와 클라이언트의 시각이 일치해야 동작합니다. 시간이 30초 이상 어긋나면 OTP가 계속 거부됩니다:

timedatectl status
# System clock synchronized: yes 가 떠야 함

 

4. 여러 서버가 있다면 각각 설정

EC2 인스턴스별로 독립적인 리눅스 환경입니다. Staging과 Production이 분리되어 있다면 두 서버 모두 같은 작업을 반복해야 합니다. OTP 앱에는 서버별로 별개 항목으로 등록하는 것을 추천합니다.


📝 최종 정리

적용 전후 변화

항목 적용 전 적용 후
SSH 인증 요소 1개 (.pem 키) 2개 (.pem + OTP)
.pem 탈취 시 즉시 완전 침해 OTP 단계에서 차단
브루트포스 공격 키 파일만 얻으면 시도 불필요 Rate limit으로 차단
네트워크 도청 OTP 재사용 가능 한 번 쓴 코드 즉시 무효화
공격 난이도 중 (키 하나만 털면 됨) 높음 (핸드폰까지 털어야)

 

  1.  

마치며

보안은 하나의 완벽한 방어막이 아니라 여러 겹의 방어선을 쌓는 일입니다. SSH 2FA는 그 중 가장 효과적이고 가성비 좋은 한 겹입니다. 30분의 설정 시간으로 .pem 키 유출의 파급력을 극적으로 줄일 수 있으니, 아직 적용하지 않으셨다면 오늘 바로 적용해보세요.

특히 서비스에 금전적 가치가 걸려 있거나, 사용자 데이터를 다루는 서버라면 선택이 아닌 필수입니다. .pem 키는 언젠가 새어 나간다고 가정해야 하고, 그때 2차 방어선이 있는지 없는지가 회사의 존망을 가를 수 있습니다.


🏷️ 태그: #AWS #EC2 #SSH #2FA #GoogleAuthenticator #보안 #DevOps #Ubuntu #서버보안 #PAM

+ Recent posts