Journey to Security/취약점진단|모의해킹

Spring Boot 사내 포털 모의침투 - 공격 체인을 끊는 4단계 패치

Cordilog 2026. 6. 30. 17:12

이전 글에서 IntraPortal을 정찰부터 데이터 유출까지 8단계로 침투하고, 각 공격이 시작된 소스코드 라인까지 매핑했다.

이번 글에서는 그 매핑 테이블을 기반으로 하드닝을 진행한다.

공격자가 처음 닿는 외부 표면부터 차단함으로써 초기 침투 사슬을 끊어서 후속 단계가 연쇄적으로 무력화되는 것을 검증하는 것이 목표다.

 

외부에서 접근 가능한 표면을 먼저 닫으면 그 표면에 의존하던 RCE도, RCE에 의존하던 권한 상승도 시작점을 잃는다.

따라서 외부 표면 → RCE 경로 → 크레덴셜 → OS 순서로 패치를 진행한다.

Phase 대상  파일 대응 기법
1. 외부 표면 Actuator 노출, CSRF, SQLi SecurityConfig.java, application.properties, LoginController.java, login.html T1190, T1003
2. RCE 경로 Command Injection DiagController.java, setup.sh T1059
3. 크레덴셜 평문 비밀번호 db/init.sql, LoginController.java T1003
4. OS 하드닝 cron, SUID, 실행 유저 setup-cron.sh, setup-suid.sh, setup.sh T1053.003, T1548.001

Phase 1: 공격자가 가장 먼저 닿는 표면 차단

공격자가 정찰에서 가장 먼저 얻은 정보원이 Actuator였고, 초기 침투에 쓴 방법이 SQL Injection이었다.

이 두 가지와 함께 CSRF 보호가 꺼져 있는 문제까지 바로잡아야 한다.

🟢 Actuator를 인증 뒤로 숨기고 노출 목록을 줄인다

패치 전 상태를 먼저 확인하면, /actuator/env가 인증 없이 200을 반환한다.

비밀번호가 마스킹되어 있다고 해서 안전한 것이 아니다.

DB 주소·유저명·내부 경로·Java/OS 정보가 전부 노출되어 내부 구조를 파악하기에 충분하고, 결정적으로 /actuator/heapdumpJVM 메모리 덤프를 받아 그 안에서 평문 비밀번호를 직접 검색할 수 있다.

# 패치 전 — heapdump로 평문 비밀번호 추출 시연
curl -o heapdump http://localhost:8080/actuator/heapdump
strings heapdump | grep -i "Portal@DB"

 

먼저 application.properties에서 노출 엔드포인트를 health,info로 제한하고, 환경변수·설정값의 평문 출력을 차단한다.

# [HARDENING] health, info만 노출. 환경변수/설정값 평문 출력 금지.
management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=when-authorized
management.endpoint.env.show-values=NEVER
management.endpoint.configprops.show-values=NEVER
security.actuator.password=__ACTUATOR_PASSWORD__

그다음 SecurityConfig.java에서 CSRF를 다시 켜고, /actuator/**ROLE_ACTUATOR 인증 필수로 수정한다.

Actuator 전용 인메모리 계정도 등록한다.

 
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .csrf()                                              // CSRF 보호 복원
        .and()
        .authorizeRequests()
            .antMatchers("/actuator/**").hasRole("ACTUATOR") // 인증 필수
            .antMatchers("/uploads/**").denyAll()            // 직접 접근 차단
            .anyRequest().permitAll()
        .and()
        .httpBasic();                                        // Actuator용 Basic 인증
}
 

/actuator/**permitAll()에서 hasRole("ACTUATOR")로 바뀌어 인증을 요구하고, /uploads/**denyAll()로 막아 업로드 파일을 통한 웹쉘 실행을 차단하며, CSRF가 켜지면서 Spring Security가 폼마다 일회용 토큰을 발급한다.

토큰 없는 POST는 403으로 거부되므로, 토큰을 모르는 공격자의 위조 요청이 차단된다.

 

Actuator 계정 비밀번호setup.sh가 빌드 시점에 openssl rand로 생성해 자동 주입하도록 한다.

 

재빌드·재시작 후 검증하면, 인증 없는 /env/heapdump401을 반환하고, 인증을 통과하더라도 노출 목록에 없으므로 404가 된다.

허용 목록에 있는 /health인증 후 200으로 응답한다.

curl -i http://localhost:8080/actuator/env                     # 401 (무인증 차단)
curl -i -u 'actuator:<pw>' http://localhost:8080/actuator/env  # 404 (노출 목록 제외)
curl -i -u 'actuator:<pw>' http://localhost:8080/actuator/health # 200 (정상)

🟢 CSRF를 활성화했으므로 로그인 폼에 토큰을 싣는다

CSRF를 활성화한 순간 정상 로그인 폼도 토큰이 없으면 403이 된다.

Thymeleaf 폼에서 action="..." 정적 속성을 쓰는 경우 토큰이 자동 주입되지 않으므로, hidden input으로 직접 넣어준다.

<form action="/login" method="post" class="login-form">
    <input type="hidden"
           th:name="${_csrf.parameterName}"
           th:value="${_csrf.token}"/>

(th:action="@{...}" 구문을 쓰면 토큰이 자동 주입되므로 이 단계가 불필요하다.)

🟢 로그인 쿼리를 PreparedStatement로 전환한다

8단계 시나리오 공격 실습에서 SQL Injection이 성공하게 된 근본 원인은 사용자 입력을 문자열로 결합해 쿼리를 만든 것이었다.

입력을 쿼리 문장의 일부로 취급한 것이 문제였으므로, 값이 들어갈 자리를 ?로 비워두고 값을 데이터로만 바인딩하는 PreparedStatement로 바꾼다.

// 패치 후 (안전) — ? 파라미터 바인딩
String query = "SELECT id, username, password, role, full_name "
             + "FROM users WHERE username = ? AND password = ?";
PreparedStatement ps = conn.prepareStatement(query);
ps.setString(1, username);   // ? 자리에 값을 데이터로만 바인딩
ps.setString(2, password);
ResultSet rs = ps.executeQuery();
// 패치 후 (안전) — ? 파라미터 바인딩
String query = "SELECT id, username, password, role, full_name "
             + "FROM users WHERE username = ? AND password = ?";
PreparedStatement ps = conn.prepareStatement(query);
ps.setString(1, username);   // ? 자리에 값을 데이터로만 바인딩
ps.setString(2, password);
ResultSet rs = ps.executeQuery();

패치 전에는 e.getMessage()를 화면에 그대로 노출해 DB 오류 메시지가 새어 나갔고, 이것이 에러 기반 SQLi의 단서가 된다.

상세는 서버 로그로만 남기고 사용자에게는 "아이디 또는 비밀번호가 잘못되었습니다" 같은 일반 메시지만 보여주는 방향으로 수정한다.

 

이렇게 하면 ' OR '1'='1' -- 페이로드는 차단되고 정상 계정(admin / admin123)만 로그인된다.

 

여기까지가 Phase 1이다.

 

외부에서 접근 가능한 3가지 취약점인 Actuator 노출, CSRF 미적용, SQLi 가 제거되어 공격자의 초기 침투 경로와 정찰 정보원이 모두 차단됐다.

Phase 2: 셸을 거치지 않도록 수정해서 RCE를 차단

원격 코드 실행으로 이어진 명령 주입(CMDi)을 차단한다.

문제의 본질은 진단 기능이 Runtime.exec("/bin/sh -c ping -c 4 " + target) 형태로 셸을 거쳐 명령을 실행한다는 데 있다.

셸이 ;, |, && 같은 메타문자를 해석하기 때문에 추가 명령이 실행될 여지가 생긴다.

셸을 아예 거치지 않게 하고, 입력을 검증함으로써 이 문제를 해결한다.

// 클래스 상수: IP/호스트명만 허용
private static final Pattern VALID_TARGET =
        Pattern.compile("^[a-zA-Z0-9.-]+$");

try {
    // [HARDENING] 입력 검증
    if (!VALID_TARGET.matcher(target).matches()) {
        model.addAttribute("output", "잘못된 대상입니다.");
        return "diag";
    }
    // [HARDENING] 셸 제거. ping 직접 실행, 인자 배열 분리.
    Process proc = new ProcessBuilder("ping", "-c", "4", target)
            .redirectErrorStream(true)
            .start();
    // ... 출력 읽기 ...
}

Runtime.exec("/bin/sh -c ...")ProcessBuilder("ping", "-c", "4", target)로 바꾼 것이 핵심이다.

인자를 배열로 분리하면 target은 ping의 단일 인자로만 전달되고, 셸을 거치지 않으므로 세미콜론은 명령 구분자가 아니라 그냥 문자열이 된다.

여기에 정규식으로 IP/호스트명 형식만 통과시키는 사전 검증을 더해 이중으로 막는다.

/admin/diagPOST 폼이므로 diag.htmlCSRF 토큰을 추가한다.

검증하면 기능은 유지되면서 주입만 차단된다.

입력 결과
127.0.0.1 정상 ping 출력 (기능 유지)
127.0.0.1; cat /etc/passwd CMDi 차단
; id CMDi 차단

입력이 셸을 거치지 않고 정규식으로 사전 검증되면서, 명령 주입을 통한 리버스 셸 획득 경로가 막혔다.

Phase 3: 시드 데이터를 BCrypt 해시로 교체해서 비밀번호 노출 차단

DB에 평문으로 저장된 비밀번호는 DB가 한 번 유출되는 순간 전량이 즉시 노출된다(T1003).

이전 실습에서 mysqldump 한 줄로 사용자 4명의 비밀번호가 그대로 빠져나간 것을 확인했다.

여기서는 시드 데이터를 BCrypt 해시로 교체하고, 로그인 검증 로직을 해시 비교로 전환한다.

 

init.sql의 시드 비밀번호를 해시값으로 바꾼다.

-- 시드 데이터: BCrypt 해시로 저장 (평문 저장 방지, T1003 차단)
-- 원본: admin=admin123, devuser=DevP@ss2024, ...
INSERT INTO users (username, password, role, full_name, email) VALUES
('admin',   '$2a$10$frjrGadNj/aOIYIyLjobnu...', 'admin',     '관리자',      'admin@internal.corp'),
('devuser', '$2a$10$nz/1.pelxix10TB61cGn9u...', 'developer', '개발팀 김개발', 'dev@internal.corp');

이제 로그인 검증도 바꿔야 한다.

패치 전에는 WHERE username = ? AND password = ?로 비밀번호까지 SQL에서 대조했지만, 해시로 저장되면 이 방식이 통하지 않는다.

username으로만 행을 조회한 뒤, 저장된 해시를 BCryptPasswordEncoder.matches()로 검증하는 구조로 전환한다.

// [HARDENING] username으로만 조회 (password는 SQL로 대조하지 않음)
String query = "SELECT id, username, password, role, full_name "
             + "FROM users WHERE username = ?";
// ...
if (rs.next()) {
    String storedHash = rs.getString("password");
    BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    if (encoder.matches(password, storedHash)) {   // 해시 검증
        session.setAttribute("username", rs.getString("username"));
        return "redirect:/dashboard";
    }
}

DB를 재생성한 뒤 확인하면, users 테이블에는 $2a$10$... 해시값만 남는다.

비밀번호 원본은 그대로지만 DB에는 해시만 저장된 상태다.

기존의 admin / admin123으로 로그인하면 정상 진입이 된다.

 

다만 이 실습은 setup.sh가 DB를 매번 새로 구성하므로 재시드로 해시를 적용하지만, 실제 운영 환경이라면 기존 평문 비밀번호를 마이그레이션 스크립트로 일괄 해싱하는 절차가 따로 필요하다.

 

📌 BCrypt를 쓰는 이유는 단순히 단방향 해시여서가 아니다.

해시는 원문에서 만들 수는 있어도 수학적으로 역산해 원문을 복원할 수는 없다.

다만 이것만으로는 부족한데, 공격자는 역산 대신 후보 비밀번호를 직접 해싱해 탈취한 값과 맞춰보는 무차별·사전 대입(오프라인 크래킹)을 시도할 수 있기 때문이다.

그러나 BCrypt는 의도적으로 느리게 설계된 해시이고 연산 비용(cost factor)을 조절할 수 있어, 한 번의 추측에 드는 시간이 SHA-512 같은 범용 해시보다 수천 배 크다. DB가 통째로 유출돼도 공격자가 얻을 수 있는 것은 해시값뿐이고, 이를 평문으로 되돌리려면 계정마다 막대한 연산을 들여야 하므로 즉시 전량 노출이 비용 높은 작업으로 바뀐다.

Phase 4: 코드 밖, OS 권한의 토대를 제거한다

지금까지가 코드 취약점이었다면, 마지막은 cron 스크립트 권한·SUID 비트·앱 실행 유저처럼 코드 밖에 있는 취약점이다. 앞 글에서 권한 상승(T1548.001)과 지속성 확보(T1053.003)의 발판이 된 것들이다.

먼저 SUID부터다. setup-suid.sh 호출이 find에 SUID 비트를 다는 원인이므로, setup.sh에서 이 호출을 제거한다.

그러면 재실행해도 find에 SUID가 붙지 않아 find / -exec /bin/sh -p 권한 상승이 불가능해진다.

 

다음은 cron 스크립트 권한이다.

777로 설정되어 있던 파일을 root만 수정할 수 있게 755로 바꾸고, 소유권도 www-data 계정이 아니라 root 계정으로 옮긴다.

구분 패치 전 (취약) 패치 후 (안전)
권한 chmod 777 /opt/backup.sh chown root:root + chmod 755
등록 위치 www-data crontab root crontab
# [HARDENING] root 소유 + 755. 소유자(root)만 수정 가능
chown root:root /opt/backup.sh
chmod 755 /opt/backup.sh

마지막으로 앱 실행 유저를 분리한다.

공격자가 획득하고 이용한 권한은 www-data였는데, 이 계정은 웹 서버 외에도 쓰임이 많아 탈취 시 영향 범위가 넓다.

로그인 불가한 전용 유저 intraportal을 만들어 앱을 그 권한으로 실행하고, uploads 디렉토리 권한도 750으로 조인다.

# [HARDENING] 앱 전용 유저 생성 (로그인 불가, 최소 권한)
useradd -r -s /usr/sbin/nologin intraportal
chmod 750 "$APP_DIR/uploads"
chown -R intraportal:intraportal "$APP_DIR"
# systemd Unit:  User=intraportal   (전: User=www-data)

한 가지 주의할 점은, 새 setup.sh는 SUID를 더 이상 설치하지 않지만 이미 설정된 흔적은 지우지 않는다는 것이다.

재실행 전에 기존 흔적을 수동으로 제거해야 한다.

sudo chmod u-s /usr/bin/find
sudo rm -f /usr/local/bin/statuscheck
sudo crontab -u www-data -r

패치 후 동적 검증으로 실제 차단을 확인한다.

www-data 권한으로 SUID 권한 상승과 cron 스크립트 변조를 모두 시도해보면 둘 다 막힌다.

# 권한 상승 차단 테스트
sudo -u www-data find / -exec /bin/sh -p \; -quit
id   # → uid=33(www-data) ...  root 안 됨

# cron 스크립트 변조 차단 테스트
sudo -u www-data bash -c 'echo "evil" >> /opt/backup.sh'
# → Permission denied   (755라 수정 불가)

www-data가 탈취되더라도 root 전환과 cron 스크립트 변조가 모두 불가능해졌다.

권한 상승과 지속성의 토대가 제거된 것이다.

하드닝 후, 같은 공격을 다시 돌려보기

패치를 마쳤으니 기존 8단계 공격 시나리오를 그대로 재시도해본다.

외부 표면만 막아도 초기 침투가 끊기면서 뒤따르던 단계까지 줄줄이 도달 불가능해지는 것을 확인할 수 있따.

 

SQLi·Actuator·CMDi·cron·SUID는 공격 자체가 불가능해지는 완전 차단이 된다.

다만, T1003 크레덴셜 수집은 완화에 그친다. root 권한을 쥔 공격자가 DB를 덤프하는 것 자체는 여전히 가능하기 때문이다.

물론 덤프 결과가 평문이 아니라 BCrypt 해시이므로, 즉시 전량 노출이 오프라인 크래킹이라는 비용 높은 작업이 된다.

 

애초에 Phase 1에서 초기 침투(SQLi)와 RCE(CMDi)가 막히면 공격자는 서버 진입조차 못 하므로, T1003에 도달할 일 자체가 사라진다. 

 

이번 하드닝 실습을 통해 단일 계층에 의존하지 않고 코드·OS·인프라 세 계층에 통제를 중첩한 심층 방어(Defense in Depth) 구조를 경험해 보았다.

 

실습의 한계점 및 향후 과제

이번 실습은 단일 대상에서 공격과 방어를 한 흐름으로 검증하는 데 집중한 만큼, 실무 적용을 위해 보완할 지점이 남아
있다.

 

▪ 자동화된 회귀 검증 부재
현재 패치 검증은 수동 curl·브라우저 확인에 의존한다. OWASP ZAP·sqlmap 등을 활용한 자동 회귀 테스트를 CI/CD 파
이프라인에 통합하면, 코드 변경 시 기존 패치가 무효화되지 않았는지 반복 검증할 수 있다.

 

▪ 런타임 탐지 계층 부재
현재 통제는 예방(prevention)에 집중되어 있다. IDS/IPS 연동이나 WAF(Web Application Firewall) 도입으로 예방을
우회한 공격을 실시간으로 탐지·차단하는 탐지(detection) 계층을 더하면, 예방과 탐지를 아우르는 심층 방어 구조가 완성
된다.

 

▪ 세션 관리 미흡
현재 로그인은 세션 쿠키 발급까지만 구현되어 있으며, 세션 타임아웃·동시 로그인 제한·로그인 성공 시 세션 ID 재발급
(Session Fixation 방지) 등의 세션 관리 통제가 적용되지 않았다. 세션 탈취 시 피해 범위를 제한하려면 이러한 통제가 필
요하다.

 

▪ 브루트포스 방어 부재
로그인 시도 횟수에 대한 제한이 없어, 공격자가 크레덴셜 사전(Dictionary)이나 무차별 대입(Brute Force)으로 계정을
탈취할 수 있다. 일정 횟수 이상 실패 시 계정 잠금 또는 요청 지연(Rate Limiting)을 적용하면 자동화된 인증 공격을 효과
적으로 억제할 수 있다.

 

▪ 코드 내 크레덴셜 잔존
BCrypt 해싱으로 DB 저장 비밀번호는 보호했으나, application.properties의 DB 접속 비밀번호는 여전히 평문으로 파
일에 존재한다. 환경변수 주입이나 외부 시크릿 관리 도구를 통해 크레덴셜을 코드 저장소 밖으로 완전히 분리하는 것이 바
람직하다.

 

▪ 의존성 취약점 미점검
Spring Boot 2.7.18과 관련 라이브러리에 알려진 CVE가 존재할 수 있으나, 본 프로젝트에서는 애플리케이션 코드의 취
약점만 다루었다. OWASP Dependency-Check나 Snyk 같은 SCA(Software Composition Analysis) 도구로 서드
파티 의존성의 알려진 취약점을 점검하면 공급망 보안까지 커버리지를 확장할 수 있다.