[HTB Cyber Apocalypse 2024] Locktalk write-up (CVE-2022-39227)

    너무 별거 아니지만... 이번에 HTB Cyber Apocalypse 2024에서 풀었던 문제 중 트릭이 생소한 문제여서 write up을 써보려고 합니다

    medium으로 나온 문제이지만 난이도 자체는 많이 쉬운 문제입니다. 문제 풀 때는 정신이 없어서 원리고 나발이고 그냥 툴 써서 풀었는데, 끝나고 분석해보니 나름 재밋기도 하고 분석해본 겸 작성합니다.

    사용된 CVE가 두 개인데, CVE-2022-39227에 대한 내용을 주로 담고 있습니다.


    1. 문제 구성

    API 서버가 컨셉이고, 세 가지 API를 사용할 수 있습니다.

    • /api/v1/get_ticket : jwt token 발급
    • /api/v1/chat/{chatId} : chat 내용 확인 가능, token 필요
    • /api/v1/flag : flag 확인, admin token 필요

    2. CVE-2023-45539

    token 발급을 위해 /api/v1/get_ticket 을 접근해보면, 아래와 같이 Forbidden이 발생합니다.

    haproxy.cfg에서 관련 설정을 확인할 수 있습니다. 여기서 HAProxy는 소프트웨어 로드 밸런서입니다. 이중화에도 용이하게 사용된다고 하네요

    global
        daemon
        maxconn 256
    
    defaults
        mode http
    
        timeout connect 5000ms
        timeout client 50000ms
        timeout server 50000ms
    
    frontend haproxy
        bind 0.0.0.0:1337
        default_backend backend
    
        http-request deny if { path_beg,url_dec -i /api/v1/get_ticket }
    
    backend backend
        balance roundrobin
        server s1 0.0.0.0:5000 maxconn 32 check

    /api/v1/get_ticket 경로가 deny 된 것을 볼 수 있는데요, 저는 여기서 저 문자열만 bypass하면 될 것 같아서 /api/v1/./get_ticket 으로 요청했고, 실제로 잘 되었습니다만...

    이게 원래 이렇게 푸는 게 아니고 CVE-2023- 45539을 사용해서 푸는 거였다고 하네요

    CVE-2023- 45539는 HAProxy의 룰 검사 중 #을 URL로 인식해서 위와 같은 설정을 bypass하는 문제였다고 합니다. (URL에서 #은 원래 fragment로 취급되어서 # 뒷부분은 URL의 경로에 영향을 미치지 않음)

    가령 중요 경로 하위는 .jpg .gif 같은 static 확장자만 허용했는데, 아래와 같이 요청하는 일이 발생하는 것이죠.

    http://victim.com/admin/index.html#.png

    이렇게 하면 룰 검사를 bypass하고, 실제 요청은 # 뒤가 fragment 처리 되면서 /admin/index.html을 향하게 되므로 중요 정보가 노출될 수 있다는 문제였습니다.

    원래 의도대로 풀면 아래와 같습니다.


    3. 그래서 이제 뭐함

    이제 얻은 token으로 뭘 할 수 있을까요? /api/v1/chat/{chatID} 로 채팅들을 보는데... 내용도 너무 많고 문제는 이 chat 내용이 DB에서 오는 것도 아니고 json으로 저장된 파일을 불러오는 겁니다. 배포 파일에도 공개가 되어있어서 비밀 내용은 아닌 것 같아요. file leak이라도 해야하나 했는데 chatID를 숫자만 불러오기 때문에 쉽지 않습니다.

    따라서 flag와는 관련 없는 내용같아서 패스하고, /api/v1/flag를 사용하려면 어떻게 해야하는지 확인해봅시다.

    /api/v1/flag 를 사용하려면 token이 admin인지 확인을 하던데 어떻게 확인하는지 살펴봅시다.

    간단하게 roleuser가 주어지며, 기본적으로 생성되는 token은 roleguest로 구성됩니다.

    # python_jwt==3.3.3 as jwt
    @api_blueprint.route('/get_ticket', methods=['GET'])
    def get_ticket():
    
        claims = {
            "role": "guest", 
            "user": "guest_user"
        }
    
        token = jwt.generate_jwt(claims, current_app.config.get('JWT_SECRET_KEY'), 'PS256', datetime.timedelta(minutes=60))
        return jsonify({'ticket: ': token})

    token의 role을 검사해서 administrator인지 확인하는 것을 볼 수 있습니다.

    # authrize_roles('administrator')
    def authorize_roles(roles):
        def decorator(func):
            @wraps(func)
            def wrapper(*args, **kwargs):
                token = request.headers.get('Authorization')
    
                if not token:
                    return jsonify({'message': 'JWT token is missing or invalid.'}), 401
    
                try:
                    token = jwt.verify_jwt(token, current_app.config.get('JWT_SECRET_KEY'), ['PS256'])
                    user_role = token[1]['role']
    
                    if user_role not in roles:
                        return jsonify({'message': f'{user_role} user does not have the required authorization to access the resource.'}), 403
    
                    return func(*args, **kwargs)
                except Exception as e:
                    return jsonify({'message': 'JWT token verification failed.', 'error': str(e)}), 401
            return wrapper
        return decorator

    4. CVE-2022-39227

    여기서 token을 생성하고 관리하는 라이브러리는 python-jwt입니다. 문제에는 3.3.3 버전이 사용되었죠.

    검색해보니 3.3.3 이하 버전에는 CVE-2022-39227 취약점이 있다고 하는데, 놀랍게도 secret key 없이 권한 상승이 가능하다고 합니다.

    대회 진행 중에는 잘 나온 poc가 있어서( https://github.com/user0x1337/CVE-2022-39227 ) 그거 사용해서 후딱 풀었는데, 여기서는 소개만 해드리고 원리에 대해서 알아봅시다.

     

    GitHub - user0x1337/CVE-2022-39227: CVE-2022-39227 : Proof of Concept

    CVE-2022-39227 : Proof of Concept . Contribute to user0x1337/CVE-2022-39227 development by creating an account on GitHub.

    github.com

    JWT는 header/payload/signature 세 가지가 . 으로 구분되어 있습니다.

    공격자는 header와 payload 사이에 변조한 payload를 넣어 signature를 속임과 동시에 변조한 payload를 보낼 수 있습니다. 어떻게 진행되는지 확인해봅시다.

    우선 발급 받은 token을 .으로 나누고, payload 부분을 base64 decode 해줍니다.

    그리고 decode된 payload 에 role 부분을 강제로 변경하여 변조된 payload를 만듭니다.

    이를 다시 base64 encoding합니다..

    아래와 같이 합치면 완성입니다! 이렇게 보면 이게 왜 되는지 모르겠죠

    {"header.modify_payload":"", "protected":"header", "payload":"payload", "signature":"signature"} 이렇게 된 모습입니다.

    위에서 나온 값을 이쁘게 보면 아래와 같습니다. 이 값을 중괄호 포함하여 전부 넣어야 합니다. 이런 JWT 본 적 있으신가요

     

    JWT는 flattened 으로 표현하는 방식이 있는데,

    {
        "header":{...},
        "payload":{...},
        "signature":{...}
    }

    이렇게 표현하는 방식을 flattened이라고 합니다. 저희가 원래 알던 JWT는 compact 방식이라고 합니다.

    일부 라이브러리들은 flattened와 compact 둘 다 채용하는데 그 중 jwcryptoJWS 라이브러리가 해당됩니다.

    JWS는 signature를 검증, 생산하는 라이브러리입니다. 여기서 JWS가 갑자기 왜 나오냐

    python-jwt는 거의 모든 로직을 jwcrypto에 의존하고 있기 때문입니다. 그 중 JWS도 사용하고요. import 되는 부분을 보면 알 수 있습니다. 주로 봐야 할 부분은 JWSjson_decode 부분입니다.

    from jwcrypto.jws import JWS, JWSHeaderRegistry
    from jwcrypto.common import base64url_encode, base64url_decode, \
                                json_encode, json_decode

    아래는 python-jwtverify_jwt 함수입니다. 가져온 token을 . 으로 구분하고, 이를 json_decode 하고 있습니다.

    그리고 아래에서 JWS를 사용해서 signature의 유효성을 검사합니다.

    그럼 JWS의 코드를 보면서 어떻게 검사하는지 확인해봅시다.

    flattened 방식이 먼저 수행되고, 이 과정에서 입력된 값의 signatureprotected, payload를 사용하여 값을 검증하는 것을 볼 수 있습니다. 즉 그 외의 필드는 그냥 무시합니다. (protected는 일반 header보다 signature 검증에 대한 정보를 좀 더 담고 있지만, 거의 같은 내용입니다. 알고리즘 종류 등..)

    # flattened 방식
    try:
          djws = json_decode(raw_jws)
          if 'signatures' in djws:
              o['signatures'] = []
              for s in djws['signatures']:
                  os = self._deserialize_signature(s)
                  o['signatures'].append(os)
                  self._deserialize_b64(o, os.get('protected'))
          else:
              o = self._deserialize_signature(djws)
              self._deserialize_b64(o, o.get('protected'))
    
          if 'payload' in djws:
              if o.get('b64', True):
                  o['payload'] = base64url_decode(str(djws['payload']))
              else:
                  o['payload'] = djws['payload']
    
    # compact 방식
      except ValueError:
          data = raw_jws.split('.')
          if len(data) != 3:
              raise InvalidJWSObject('Unrecognized'
                                     ' representation') from None
          p = base64url_decode(str(data[0]))
          if len(p) > 0:
              o['protected'] = p.decode('utf-8')
              self._deserialize_b64(o, o['protected'])
          o['payload'] = base64url_decode(str(data[1]))
          o['signature'] = base64url_decode(str(data[2]))
    
      self.objects = o

    따라서 변조된 부분 중 아래의 부분만 사용하게 됩니다. 그리고 이 부분은 원래의 token의 header, payload, signature를 사용해서 구성했습니다. 따라서 검증에 아무 문제가 없습니다.

    그럼 이 token을 python-jwt에서는 어떻게 구분할까요?

    아까 처음 부분에서 .으로 구분하는 것을 확인했습니다.

    따라서 아래와 같은 일이 일어납니다. 신기하죠

    이러면 base64 decoding할 때 문제가 생기지 않을까? 싶을텐데요

    base64url_decode(jwcrypto의 함수)는 결론적으로 base64 라이브러리의 urlsafe_b64decode를 사용하는데, urlsafe_b64decode는 base64의 문자가 아니라면 무시한다고 합니다. (그냥 b64decode도 해당)


    5. solve + 여담

    이렇게 풀면 되는 문제였습니다. 알기만 하면 난이도가 굉장히 낮죠

    사실 이 라이브러리는 많이 쓰이지도 않고 이젠 개발이 중단된 것 같아서 사실 파급력이라고 할 건 없을거같아요. 문제용으로는 재밋는듯

    이런 취약점도 있더라 하는 느낌으로 분석했습니다 재밋잖아요 하하

    'write-up > CTF' 카테고리의 다른 글

    [codegate2025 qual] Web write-up  (0) 2025.04.03
    [BlackHat MEA qual CTF 2024] WEB all write-up  (0) 2024.09.03
    [CCE 2024 Qual] ccend write-up  (0) 2024.08.12
    [TFC CTF 2024] write-up  (0) 2024.08.06
    [justCTF 2023] write-up  (0) 2023.06.06

    댓글