각진 세상에 둥근 춤을 추자

개발자 도구 페이로드 평문 패스워드 노출: 클라이언트 측 RSA 암호화를 활용한 로그인 (1) 본문

보안

개발자 도구 페이로드 평문 패스워드 노출: 클라이언트 측 RSA 암호화를 활용한 로그인 (1)

circle.j 2024. 8. 22. 17:56

스프링 시큐리티 로그인 시 패스워드 노출 문제와 보안 위험성

웹 애플리케이션에서 스프링 시큐리티를 사용해 로그인 기능을 구현할 때, 
사용자가 입력한 패스워드가 서버로 전송되기 전에 페이로드에서 평문으로 노출되는 문제가 발생한다.
 
이는 개발환경에서 테스트나 디버깅을 하다 보면 쉽게 확인할 수 있는 부분인데,
이러한 평문 패스워드의 노출은 보안에 있어 잠재적인 위험 요소가 될 수 있다.

 

패스워드 평문 노출 문제점 

패스워드가 평문으로 전송된다는 점은, 만약 해당 패킷이 악의적인 접근에 의해 탈취될 경우 사용자의 민감한 정보가 쉽게 유출될 수 있음을 의미한다. 
특히, 네트워크 중간에서 패킷을 가로채는 MITM(Man-In-The-Middle) 공격이나 악성 스니핑 도구를 통해 이러한 정보가 악용될 가능성이 있다. 
 

HTTPS 사용의 필요성

HTTPS 프로토콜을 사용하면 클라이언트와 서버 간의 데이터가 암호화되어 전송되기 때문에, 네트워크 상에서 패킷을 탈취되더라도 내용이 보호된다. 그러나 HTTPS로 암호화되었다고 해서 모든 위협이 완전히 제거되는 것은 아니다. 클라이언트 사이드에서 패스워드가 평문으로 노출되는 문제는 여전히 존재하기 때문에 악성 코드나 브라우저의 취약점을 이용해 정보를 탈취하는 경우가 있기 때문이다. 
해커들이 악의적인 접근으로 패킷을 탈취하게 되면, 평문으로 전송된 패스워드는 매우 쉽게 해독될 수 있다. 
 
따라서 이러한 문제를 해결하기 위해서는 HTTPS를 반드시 사용해야 하지만, 추가적인 보안 조치도 필요하다.

 

방안 1. 클라이언트에서 패스워드를 해시 처리한 후 서버로 전송한다.
- Hash를 이용한 단방향 암호화 과정으로, 평문을 암호화할 수 있지만 복호화는 불가능하다. 
- 그러나 서버에서 해당 해시값으로 인증을 처리하는 경우, 해시값 자체가 일종의 '패스워드'로 간주될 수 있다.
방안 2. 클라이언트에서 패스워드를 대칭키 암호화 (AES)로 암호화한 후 서버로 전송한다. 
- AES(Advanced Encryption Standard)는 대칭키 암호화 알고리즘으로, 암호화와 복호화에 동일한 키를 사용한다.
- 그러나대칭키 암호화에서는 암호화와 복호화를 위해 동일한 키를 사용하므로, 키가 노출될 경우 보안이 깨진다.
- 또한 여러 클라이언트와의 통신이 있을 경우, 각 클라이언트와 고유한 대칭키를 사용해야 하므로 키 관리가 복잡해질 수 있다. 
방안 3. 클라이언트에서 패스워드를 비대칭키 암호화(RSA)로 암호화한 후 서버로 전송한다. 
- RSA(Rivest-Shamir-Adleman)는 비대칭키 암호화 알고리즘으로, 공개 키로 암호화하고 개인 키로 복호화하는 방식이다.
- 공개 키는 자유롭게 배포할 수 있기 때문에 키 교환 과정에서의 보안 문제가 없다. 클라이언트는 서버의 공개 키로 데이터를 암호화해 전송하면, 서버만이 이를 복호화할 수 있다.

 


비대칭키 암호화(RSA) 방식 

 
비대칭키 암호화(RSA)로 암호화를 진행한다는 전제 하에, 추가적으로 고려해야 할 사항이 있다.

우선 비대칭키 암호화(RSA)를 통해 패스워드를 암호화하는 경우의 순서는 다음과 같다. 

1. 로그인 페이지 로드 시, 클라이언트가 공개키를 요청한다. 
2. 서버에서 공개키와 개인키를 생성 후, 클라이언트에게 공개키를 전송한다.
3. 클라이언트에서 사용자가 입력한 패스워드를 공개키로 암호화한 후 서버에게 전송한다. 
4. 서버는 암호화된 패스워드를 개인키로 복호화한다. 
5. 이후 DB에 저장된 패스워드의 해시값과 비교(인증)한다.
 
암호화 방식에서 중요한 것은 보안성과 가용성을 모두 고려한 접근이다. 
특히 RSA와 같은 비대칭 암호화를 사용해 로그인 패스워드를 보호하는 경우, 암호화 작업이 어떤 방식으로 그리고  어느 시점에서 이루어지는지가 매우 중요하다. 
 
1. 서버 부하와 가용성 문제 
페이지가 로드될 때마다 서버에서 RSA 공개키와 개인키를 새로 생성하는 방식은 서버에 상당한 부하를 줄 수 있다. 
이는 특히 대량의 사용자가 로그인 페이지를 반복적으로 새로고침하거나 접근할 경우, 서버 성능에 영향을 미친다. 
키 생성은 연산적으로 비용이 많이 드는 작업이기 때문에 서버 자원의 효율적인 사용을 위해서는 키를 재사용하거나 일정 주기로 키를 갱신하는 방법을 고려한다.
 
2. 메모리 관리
메모리 상에 중요한 키 정보가 오래 남아 있는 것은 보안 측면에서 문제를 일으킬 수 있다.
만약 키가 메모리에서 안전하게 삭제되지 않는다면, 메모리 덤프 공격을 통해 악의적인 공격자가 이를 추출할 가능성이 있다. 따라서 키는 사용 후 즉시 메모리에서 제거되어야 한다. 


3. 보안성과 가용성의 균형
이러한 문제를 해결하기 위해서는 암호화 시점과 방식을 신중하게 결정해야 한다.


위 방식에 대한 예제 코드를 먼저 살펴본다. 
 
Liferay - RSA를 사용해 클라이언트 측에서 패스워드를 암호화하는 방식 
(출처: https://liferay.dev/blogs/-/blogs/client-side-password-encryption-)

더보기

1. 개인키와 공개키 생성 

@Override
public String render(RenderRequest renderRequest, RenderResponse renderResponse) throws PortletException {
    // ThemeDisplay와 현재 사용자의 로그인 여부 확인
    // 로그인되지 않은 상태에서만 공개 키를 생성하고 클라이언트에게 전달한다
    ThemeDisplay themeDisplay = (ThemeDisplay) renderRequest.getAttribute(WebKeys.THEME_DISPLAY);
    if (!themeDisplay.isSignedIn()) {
        KeyPair keyPair = null;
        if (Validator.isNull(renderRequest.getPortletSession().getAttribute("keyPair"))) {
            _log.info("Creating Fresh Key pair");
            KeyPairGenerator keyPairGenerator = null;
            try {
            	// 키 생성: keyPairGenerator를 사용해 1024비트 크기의 RSA 키 쌍을 생성한다.
                // KeyPiar는 공개 키와 개인 키를 모두 포함하는 객체이다. 
                keyPairGenerator = KeyPairGenerator.getInstance("RSA");
                keyPairGenerator.initialize(1024);
            } catch (NoSuchAlgorithmException e) {
                _log.error(e.getMessage());
            }
            keyPair = keyPairGenerator.generateKeyPair();
            renderRequest.getPortletSession().setAttribute("keyPair", keyPair);
        } else {
            // 세션 저장: 새로 생성한 키 쌍을 사용자의 세션(renderRequest.getPortletSession())에 저장한다.
            // 같은 세션에서 여러 번 페이지를 로드할 때마다 새로운 키 쌍을 생성하지 않고 기존의 키 쌍을 재사용한다. 
            keyPair = (KeyPair) renderRequest.getPortletSession().getAttribute("keyPair");
        }
        // 공개 키 인코딩 및 클라이언트 전달 
        String publicKey = Base64.encode(keyPair.getPublic().getEncoded());
        renderRequest.setAttribute("publicKey", publicKey);
    }
    return mvcRenderCommand.render(renderRequest, renderResponse);
}

- 사용자가 로그인하지 않은 경우에만 RSA 키 쌍을 생성한다.
- 생성된 공개 키는 클라이언트에서 사용되며, 세션을 통해 같은 세션 동안 동일한 키를 재사용한다.

위 방식에서 주의해야 할 점은, 세션이 만료되거나 새로 생성될 때마다 새로운 키를 만들어야 한다는 것이다. 


2. 공개키를 이용한 비밀번호 암호화 

if (form) {
	// 폼 제출 이벤트 리스너 
    form.addEventListener(
        'submit',
        function(event) {
        	// 패스워드 입력 필드 선택 및 값 추출 
             var password = form.querySelector('#<portlet:namespace />password');
                if(password) {
                    var passwordVal = password.getAttribute('value');
                    // JSEncrypt(자바스크립트 라이브러리)를 사용한 암호화 
                    var encrypt = new JSEncrypt();
                    // 서버에서 전달된 공개키를 설정 
                    encrypt.setPublicKey('${publicKey}');
                    // 사용자가 입력한 패스워드를 설정된 공개키를 사용해 RSA로 암호화 
                    var encrypted = encrypt.encrypt(passwordVal);
                    // 암호화된 패스워드를 다시 패스워드 입력 필드에 설정 
                   $('#<portlet:namespace />password').val(encrypted);
                }


			// 리다이렉트 처리 
            <c:if test="<%= Validator.isNotNull(redirect) %>">
                var redirect = form.querySelector('#<portlet:namespace />redirect');

                if (redirect) {
                    var redirectVal = redirect.getAttribute('value');

                    redirect.setAttribute('value', redirectVal + window.location.hash);
                }
            </c:if>
			
            // 폼 제출 
            submitForm(form);
        }
    );

 

3. 서버 측에서 클라이언트가 전송한 암호화된 패스워드를 복호화

@Override
protected void doProcessAction(ActionRequest actionRequest, ActionResponse actionResponse) throws Exception {

    DynamicActionRequest dynamicActionRequest = new DynamicActionRequest(actionRequest);
    dynamicActionRequest.setParameter("password", decrypt(actionRequest, ParamUtil.getString(actionRequest, "password")));
    mvcActionCommand.processAction(dynamicActionRequest, actionResponse);
}

private String  decrypt(PortletRequest portletRequest, String value) {        
    KeyPair keyPair = (KeyPair) portletRequest.getPortletSession().getAttribute("keyPair");

    String decryptedValue  = StringPool.BLANK;
    Cipher cipher = null;
     try {
         cipher = Cipher.getInstance("RSA");
    } catch (NoSuchAlgorithmException e) {
        _log.error(e.getMessage());
    } catch (NoSuchPaddingException e) {
        _log.error(e.getMessage());
    }        
    try {
        cipher.init(Cipher.DECRYPT_MODE, keyPair.getPrivate());
    } catch (InvalidKeyException e) {
        _log.error(e.getMessage());
    }
    try {
        decryptedValue = new String(cipher.doFinal(Base64.decode(value)));
    } catch (IllegalBlockSizeException e) {
        _log.error(e.getMessage());
    } catch (BadPaddingException e) {
        _log.error(e.getMessage());
    }
         return decryptedValue;
}

 

즉 앞의 문제들을 살펴 정리해보자면,

로그인 페이지 로드 시, 클라이언트는 서버에게 공개키를 요청하고 서버는 공개키와 개인키를 생성한다.

그러나, 페이지 로드시마다 키 쌍 생성 시 서버의 과부하 

세션 기반 키 재사용 방법 (사용자의 세션이 시작될 때 한 번만 키 쌍을 생성하고, 세션이 유지되는 동안 키 쌍을 재사용)

그러나, 세션 기반 키 재사용은 단일  사용자가 페이지를 여러 번 새로고침하는 경우에만 효과적,
다수의 사용자가 각기 다른 세션을 사용할 경우, 여전히 각 세션마다 서버에서 키 쌍을 생성해야 함

시간 단위 키 생성 방법 ( 일정 시간마다 새로운 RSA 키 쌍을 생성, 그 기간 동안 모든 클라이언트가 동일한 공개키 사용)

그러나, 공개키가 일정 시간 모든 사용자에게 동일하게 제공되므로, 공격자가 이 공개키를 쉽게 수집할 수 있다. 
만약 해당 키가 손상되면 기간 동안 전송된 모든 패스워드가 위험에 처할 수 있다.
또한 공격자가 특정 사용자의 암호화된 패스워드를 가로채면, 동일한 키를 사용해 해당 패스워드를 재전송하는 방식으로
로그인 시도를 하는 재전송 공격이 발생할 수 있다. 

요약하자면 다음과 같다.

[문제 요약]
문제: 로그인 시 개발자 도구의 페이로드에 패스워드가 평문으로 노출되고 있다.
- 클라이언트 측에서 사용자가 입력한 패스워드를 비대칭 암호화(RSA) 방식으로 패스워드를 암호화해서 서버에 전송한다.
- 비대칭 암호화 방식을 사용하기 위해서는 클라이언트가 서버에게 공개키를 요청하고 서버가 RSA 키 쌍을 생성해야 한다.
- 로그인 페이지 로드 시, 클라이언트가 공개키를 요청하면? 페이지가 로드될 때마다 서버가 키쌍을 생성해야 하므로 서버가 과부하되는 문제가 발생한다.

 

그렇다면 어느 시점에 클라이언트가 공개키 요청을 하는게 좋을까?

 
공개키 요청 시점을 효율적으로 설정하는 방법에 생각해 본다. 


 

클라이언트의 공개키 요청 시점


1. 로그인 버튼 클릭 시점에 공개키 요청 
로그인 페이지가 로드될 때마다 공개키를 요청하지 않고, 사용자가 로그인 버튼을 클릭하는 시점에 공개키를 요청한다.
- △ 로그인 버튼을 클릭할 때마다 추가적인 네트워크 요청이 발생하기 때문에, 사용자 경험(UX) 측면에 속도가 다소 느려질 우려가 있다. -> 비동기 요청 처리(AJAX) 사용 
- △ 해커가 악의적으로 로그인 버튼을 반복해서 클릭하는 Dos(서비스 거부) 공격을 시도하는 경우
   (1) 요청 속도 제한
     - IP 기반 요청 제한: 일정 시간 내에 특정 IP에서 지나치게 많은 요청이 발생하면 해당 IP의 요청을 차단하거나 지연시킨다. (예: 동일한 IP에서 1분에 5회 이상 로그인 시도가 발생하면 그 이후 요청을 일정 시간 동안 거부하거나 대기시킨다.)
   (2) CAPTCHA 적용: 로그인 시도 횟수가 일정 수를 초과할 경우 CAPTCHA를 도입하여 자동화된 공격을 방어한다.  

2. 세션이 '처음' 생성될 때 공개키 요청
서버는 사용자의 세션이 처음 생성될 때 RSA 키 쌍을 생성하고, 그 세션 동안 공개키를 재사용한다. 
- 키가 사용자의 세션 동안 유지되므로, 여러 페이지 새로고침이나 재접속에도 서버 부하를 줄일 수 있다.
-  △ 각 사용자의 세션마다 여전히 키를 생성해야 하므로 다수의 사용자가 있을 경우 서버 부하가 발생할 수 있다.

3. 페이지 로드 직후 비동기적으로 공개키 요청
페이지 로드 시 공개키를 요청하되 페이지가 로드된 후 비동기 요청(AJAX)을 통해 서버에 키를 요청한다.
-  △ 여전히 페이지가 로드될 때마다 공개키가 요청되므로, 세션이 새로 시작될 때마다 서버 부하가 발생할 수 있다.