금융권 인증 우회 취약점 실전 시나리오

WEB-SER-019: 불충분한 이용자 인증 | 위험도: 4 (높음)

🔴 공격자 (해커)

인증 우회 준비 중...

https://xxbank.com

시나리오를 시작하세요

공격 진행 단계

시나리오를 시작하세요...

실전 공격 시나리오

취약한 코드 (인증 미흡)

문제 원인
// Java Spring - 취약한 인증 체크

@Controller
public class TransferController {
    
    @GetMapping("/transfer")
    public String transferPage(Model model) {
        // ❌ 인증 체크 없음!
        // 누구나 URL 직접 입력으로 접근 가능
        return "transfer";
    }
    
    @PostMapping("/transfer")
    public ResponseEntity transfer(
        @RequestBody TransferRequest request
    ) {
        // ❌ 세션 검증 없음!
        transferService.execute(
            request.getFromAccount(),
            request.getToAccount(),
            request.getAmount()
        );
        
        return ResponseEntity.ok("송금 완료");
    }
}

@Controller
public class PasswordController {
    
    @GetMapping("/change-password")
    public String changePasswordPage() {
        // ❌ 재인증 없음!
        return "change-password";
    }
    
    @PostMapping("/change-password")
    public ResponseEntity changePassword(
        @RequestBody PasswordRequest request
    ) {
        // ❌ 현재 비밀번호 확인 없음!
        userService.updatePassword(
            request.getUserId(),
            request.getNewPassword()
        );
        
        return ResponseEntity.ok("변경 완료");
    }
}

@Controller
public class AdminController {
    
    @GetMapping("/admin")
    public String adminPage(HttpSession session) {
        // ❌ 클라이언트 검증만 존재!
        return "admin";
    }
}

// admin.jsp - 클라이언트 검증

<script>
    // ❌ JavaScript로만 관리자 체크!
    if (localStorage.getItem('role') !== 'admin') {
        alert('관리자만 접근 가능합니다.');
        location.href = '/';
    }
</script>

<div id="adminPanel">
    <h1>관리자 페이지</h1>
    <button onclick="deleteAllUsers()">
        전체 사용자 삭제
    </button>
</div>

// Python Flask - 취약한 인증

@app.route('/transfer', methods=['GET', 'POST'])
def transfer():
    # ❌ 세션 체크 없음!
    if request.method == 'GET':
        return render_template('transfer.html')
    
    # ❌ 인증 없이 송금 처리
    from_account = request.json['from_account']
    to_account = request.json['to_account']
    amount = request.json['amount']
    
    execute_transfer(from_account, to_account, amount)
    return jsonify({"status": "success"})

@app.route('/change-password', methods=['POST'])
def change_password():
    # ❌ 현재 비밀번호 확인 없음!
    user_id = request.json['user_id']
    new_password = request.json['new_password']
    
    update_password(user_id, new_password)
    return jsonify({"status": "success"})

// JavaScript/Node.js - 취약한 플로우

// ❌ 단계별 검증 없음!
app.post('/register/step2', (req, res) => {
    // step1 완료 여부 확인 없음!
    const { address } = req.body;
    saveAddress(address);
    res.json({ next: '/register/step3' });
});

app.post('/register/step3', (req, res) => {
    // step2 완료 여부 확인 없음!
    const { ssn } = req.body;
    saveSsn(ssn);
    res.json({ status: 'complete' });
});

🟢 서버 처리 로그

인증 검증 없이 처리

[SERVER] Waiting...
서버 상태: RUNNING

안전한 코드 (인증 검증)

해결책
// Java Spring - 안전한 인증 체크

// ✅ 방법 1: Spring Security 사용
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(
        HttpSecurity http
    ) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                // 인증 필요한 경로 지정
                .requestMatchers("/transfer/**")
                    .authenticated()
                .requestMatchers("/change-password")
                    .authenticated()
                .requestMatchers("/admin/**")
                    .hasRole("ADMIN")
                .anyRequest().permitAll()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/")
            )
            .sessionManagement(session -> session
                // 세션 고정 공격 방어
                .sessionFixation()
                    .newSession()
                // 세션 타임아웃 10분
                .maximumSessions(1)
                .maxSessionsPreventsLogin(true)
            );
        
        return http.build();
    }
}

// ✅ 방법 2: 커스텀 인증 인터셉터
@Component
public class AuthenticationInterceptor 
    implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler
    ) throws Exception {
        
        HttpSession session = request.getSession(false);
        
        // 세션 존재 여부 확인
        if (session == null) {
            response.sendRedirect("/login");
            return false;
        }
        
        // 사용자 인증 정보 확인
        User user = (User) session.getAttribute("user");
        if (user == null) {
            response.sendRedirect("/login");
            return false;
        }
        
        // 세션 타임아웃 체크 (10분)
        long lastAccessTime = session.getLastAccessedTime();
        long currentTime = System.currentTimeMillis();
        long timeout = 10 * 60 * 1000; // 10분
        
        if (currentTime - lastAccessTime > timeout) {
            session.invalidate();
            response.sendRedirect("/login");
            return false;
        }
        
        // 관리자 권한 체크 (admin 경로)
        String path = request.getRequestURI();
        if (path.startsWith("/admin") && 
            !"ADMIN".equals(user.getRole())) {
            response.sendError(
                HttpServletResponse.SC_FORBIDDEN,
                "접근 권한이 없습니다."
            );
            return false;
        }
        
        return true;
    }
}

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    
    @Autowired
    private AuthenticationInterceptor authInterceptor;
    
    @Override
    public void addInterceptors(
        InterceptorRegistry registry
    ) {
        registry.addInterceptor(authInterceptor)
            // 인증 필요한 경로 지정
            .addPathPatterns("/transfer/**")
            .addPathPatterns("/change-password")
            .addPathPatterns("/admin/**");
    }
}

// ✅ 안전한 송금 컨트롤러
@Controller
@RequiredArgsConstructor
public class SecureTransferController {
    
    private final TransferService transferService;
    
    @GetMapping("/transfer")
    public String transferPage(
        HttpSession session, 
        Model model
    ) {
        // 인증 확인
        User user = (User) session.getAttribute("user");
        if (user == null) {
            return "redirect:/login";
        }
        
        model.addAttribute("user", user);
        return "transfer";
    }
    
    @PostMapping("/transfer")
    public ResponseEntity transfer(
        @RequestBody TransferRequest request,
        HttpSession session
    ) {
        // 1. 세션 검증
        User user = (User) session.getAttribute("user");
        if (user == null) {
            return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body("인증이 필요합니다.");
        }
        
        // 2. 계좌 소유자 확인
        if (!user.getId().equals(
            request.getFromAccountOwnerId()
        )) {
            return ResponseEntity
                .status(HttpStatus.FORBIDDEN)
                .body("본인 계좌만 사용 가능합니다.");
        }
        
        // 3. 추가 인증 (OTP)
        if (!otpService.verify(
            user.getId(), 
            request.getOtp()
        )) {
            return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body("OTP 인증에 실패했습니다.");
        }
        
        // 4. 송금 실행
        transferService.execute(
            user.getId(),
            request.getFromAccount(),
            request.getToAccount(),
            request.getAmount()
        );
        
        return ResponseEntity.ok("송금 완료");
    }
}

// ✅ 안전한 비밀번호 변경
@Controller
@RequiredArgsConstructor
public class SecurePasswordController {
    
    private final UserService userService;
    
    @GetMapping("/change-password")
    public String changePasswordPage(
        HttpSession session
    ) {
        // 인증 확인
        if (session.getAttribute("user") == null) {
            return "redirect:/login";
        }
        
        // 재인증 플래그 초기화
        session.removeAttribute("reauth_verified");
        
        return "change-password";
    }
    
    @PostMapping("/verify-current-password")
    public ResponseEntity verifyCurrentPassword(
        @RequestBody Map request,
        HttpSession session
    ) {
        User user = (User) session.getAttribute("user");
        String currentPassword = request.get("password");
        
        // 현재 비밀번호 확인
        if (userService.verifyPassword(
            user.getId(), 
            currentPassword
        )) {
            // 재인증 완료 플래그
            session.setAttribute(
                "reauth_verified", 
                true
            );
            session.setAttribute(
                "reauth_time",
                System.currentTimeMillis()
            );
            
            return ResponseEntity.ok("인증 완료");
        } else {
            return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body("비밀번호가 일치하지 않습니다.");
        }
    }
    
    @PostMapping("/change-password")
    public ResponseEntity changePassword(
        @RequestBody PasswordRequest request,
        HttpSession session
    ) {
        User user = (User) session.getAttribute("user");
        
        // 1. 재인증 확인
        Boolean reauthVerified = (Boolean) session
            .getAttribute("reauth_verified");
        
        if (reauthVerified == null || !reauthVerified) {
            return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body("현재 비밀번호를 먼저 확인해주세요.");
        }
        
        // 2. 재인증 시간 확인 (5분 이내)
        Long reauthTime = (Long) session
            .getAttribute("reauth_time");
        long currentTime = System.currentTimeMillis();
        
        if (currentTime - reauthTime > 5 * 60 * 1000) {
            session.removeAttribute("reauth_verified");
            return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body("재인증 시간이 만료되었습니다.");
        }
        
        // 3. 비밀번호 변경
        userService.updatePassword(
            user.getId(),
            request.getNewPassword()
        );
        
        // 4. 재인증 플래그 제거
        session.removeAttribute("reauth_verified");
        session.removeAttribute("reauth_time");
        
        return ResponseEntity.ok("변경 완료");
    }
}

// Python Flask - 안전한 인증

from functools import wraps
from flask import session, redirect, request

# ✅ 인증 데코레이터
def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        # 세션 확인
        if 'user_id' not in session:
            return redirect('/login')
        
        # 세션 타임아웃 체크 (10분)
        last_activity = session.get('last_activity')
        if last_activity:
            elapsed = time.time() - last_activity
            if elapsed > 600:  # 10분
                session.clear()
                return redirect('/login')
        
        # 활동 시간 업데이트
        session['last_activity'] = time.time()
        
        return f(*args, **kwargs)
    return decorated_function

# ✅ 관리자 권한 체크
def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user_id' not in session:
            return redirect('/login')
        
        # 관리자 권한 확인
        user = get_user(session['user_id'])
        if user.role != 'ADMIN':
            return jsonify({
                "error": "접근 권한이 없습니다."
            }), 403
        
        return f(*args, **kwargs)
    return decorated_function

@app.route('/transfer', methods=['GET', 'POST'])
@login_required
def transfer():
    if request.method == 'GET':
        return render_template('transfer.html')
    
    # 사용자 확인
    user_id = session['user_id']
    from_account = request.json['from_account']
    
    # 계좌 소유자 확인
    account = get_account(from_account)
    if account.owner_id != user_id:
        return jsonify({
            "error": "본인 계좌만 사용 가능합니다."
        }), 403
    
    # OTP 확인
    otp = request.json.get('otp')
    if not verify_otp(user_id, otp):
        return jsonify({
            "error": "OTP 인증에 실패했습니다."
        }), 401
    
    execute_transfer(
        from_account,
        request.json['to_account'],
        request.json['amount']
    )
    
    return jsonify({"status": "success"})

@app.route('/change-password', methods=['POST'])
@login_required
def change_password():
    user_id = session['user_id']
    
    # 재인증 확인
    if not session.get('reauth_verified'):
        return jsonify({
            "error": "현재 비밀번호를 먼저 확인해주세요."
        }), 401
    
    # 재인증 시간 확인 (5분)
    reauth_time = session.get('reauth_time')
    if time.time() - reauth_time > 300:
        session.pop('reauth_verified', None)
        return jsonify({
            "error": "재인증 시간이 만료되었습니다."
        }), 401
    
    # 비밀번호 변경
    update_password(
        user_id,
        request.json['new_password']
    )
    
    # 재인증 플래그 제거
    session.pop('reauth_verified', None)
    session.pop('reauth_time', None)
    
    return jsonify({"status": "success"})

@app.route('/admin')
@admin_required
def admin_page():
    return render_template('admin.html')

// JavaScript/Node.js - 안전한 플로우

// ✅ 세션 기반 플로우 관리
app.post('/register/step2', (req, res) => {
    const session = req.session;
    
    // step1 완료 확인
    if (!session.step1_completed) {
        return res.status(400).json({
            error: "이전 단계를 먼저 완료해주세요."
        });
    }
    
    const { address } = req.body;
    saveAddress(session.user_id, address);
    
    // step2 완료 표시
    session.step2_completed = true;
    
    res.json({ next: '/register/step3' });
});

app.post('/register/step3', (req, res) => {
    const session = req.session;
    
    // 이전 단계 확인
    if (!session.step1_completed || 
        !session.step2_completed) {
        return res.status(400).json({
            error: "이전 단계를 먼저 완료해주세요."
        });
    }
    
    const { ssn } = req.body;
    saveSsn(session.user_id, ssn);
    
    // 플로우 완료 - 세션 정리
    delete session.step1_completed;
    delete session.step2_completed;
    
    res.json({ status: 'complete' });
});