防御CC攻击

什么是CC攻击

CC攻击,英文Challenge Collapsar,译为“挑战黑洞”,是一种以服务系统性能薄弱环节为目标的分布式拒绝服务(DDoS)攻击。传统的DDoS攻击一般利用受害者服务器底层网络技术的缺陷发动攻击,攻击者流量消耗比较小。随着技术的进步,已经基本实现有效防御。CC攻击则和传统的DDoS攻击方式不同,它针对的是业务系统应用层的薄弱环节,攻击者需要消耗较多的网络带宽才能发动,但由于根据业务特点发动攻击,目前没有通用的有效的防御手段,所以攻击成功率比较高。

服务器业务中的性能薄弱环节,未必是软件实施的缺陷。比如常见的CMS网站,首页在Cache等技术下,承载上万RPS(每秒请求)不会有任何问题,但是CMS的搜索功能,也许只能承载几百RPS。考虑到平时网站用户量不大,实际首页压力只有几百RPS,而搜索只有几RPS的实际情况,网站完全能够正常运行。此时如果黑客断定搜索是性能薄弱环节,针对搜索功能发起CC攻击,通过遍布全国的几百个代理服务器,向网站发送搜索请求,很快网站就会不堪重负,被迫下线了。

网站中需要处理复杂事务的接口,一般都有可能会成为CC攻击的目标,包括搜索、聊天、短信发送、验证码生成等等。

侦测CC攻击

防御CC攻击较之防御传统的DDoS攻击,一大难点是不易侦测攻击的存在。从单个请求来看,CC攻击者和普通用户几乎完全相同,一般只能从宏观层面,比如一段时间的网站流量统计中才能发现自己被攻击了。找到方法甄别来访者是普通用户还是攻击者,是解决CC攻击的关键。

检查代理服务器

一般用户都是直接访问互联网,而大多数CC攻击采用代理服务器发起,检测来访用户是否使用代理服务器是一个判别的方法。公共HTTP代理一般会在请求中加入X-Forwarded-ForVia请求标头,可以以此为据侦测用户是否使用了代理服务器。

不过近年出现了不少高匿名的代理服务器,完全不透传任何信息,这个方法就无能为力了。

检查IP的并发连接数

根据HTTP协议的规定,用户代理对每个域应该允许最多2个并发连接,这一标准在06年以来有所提高,每个域允许6个并发连接。正常情况下,每个连接到服务器的IP处于ESTABLISHED状态的连接数应该不超过n*6个,n是该服务器同时对外服务的域名数量。如果来自同一个IP存在大量的并发连接,则有可能是攻击者。我们可以使用netstat命令实时地查看TCP连接的情况,配合脚本可以统计出当前各个客户端IP的并发连接数。

当然这个方法并不绝对,有的网吧几十台机器共用一个IP作为出口,那么这个IP的并发连接数量就会大得多。还有一些小的ISP可能只有少量的公网IP,它们在建设小区宽带的时候甚至会让一幢楼的用户共用一个公网IP。

高频请求检测

发动CC攻击一般都用脚本让机器自动发起请求,如果发现某个IP以非常高的频率发起HTTP请求,这个IP就有可能是攻击者。收集和处理高频请求的数据,需要占用一些服务器的CPU和内存资源。判定是否高频,一般以正常用户的页面平均停留时间乘以一个系数来计算需要根据实际情况不断调整。

这一方法和前面的并发连接数法有着相同的问题,如果用户存在共用出口公网IP的情况,就可能误判。另一个问题是,如果攻击者采用游击战术,每个代理只发起一波请求,然后弃之不用,这一方法就不会有实际效果,因为发现攻击者之后,攻击者已经换地方了。

重复IP检测

对于低留存率的网站,可以计算出连续若干天的来访者IP交集,去掉搜索引擎的和白名单上的IP之后,可以认为这些IP是攻击者。

这一方法的局限性在于需要攻击持续一段时间,并且攻击者只使用少数固定的代理服务器。如果攻击者打游击,这一方法也不会有效。另外,高留存率的网站可能不适用此方法,因为本来用户就会来了又来。

浏览器特性检测

有的CC攻击发起者只是为了对服务器的复杂逻辑发起请求,让服务器负担加大。有的攻击者会放弃使用全功能的浏览器,而改用一般的Python脚本发起。如果攻击者使用的不是完整的浏览器,我们就可以下发JS脚本运行,并用Feature Hack的方法确认用户的浏览器和它在UA中声称的自己的身份是否一致,进而推断是否为攻击者。

反向扫描来访IP的端口

一般的家用宽带用户不会开启大量非常用端口,反向扫描来访者IP开启的端口情况,如果发现对方在Listen许多非常用端口,则说明对方很可能是一台代理服务器或者被黑客操控的肉机。

反向扫描的方法耗时比较长,对一波流的攻击者效果不显著。

业务保护

如果我们无法有效辨别一个来访请求是CC攻击还是普通用户,也可以从业务角度入手,保护复杂业务正常进行。CC攻击是由机器自动完成,如果被攻击的功能能够识别人类用户,虽然无法降低带宽成本,但是可以有效减少业务损失。

加验证码

在关键功能上加验证码是一种直接的方法,可以参考12306的登录功能,使用验证码保护登录接口不会被机器自动提交攻击。

对抗非浏览器

如果攻击者使用的客户端不是浏览器,无法运行网页中的JavaScript程序,可以在页面中加入JavaScript验证程序,让一般的网页抓取工具失效。Wordpress的反垃圾留言就有类似的实现方法,它使用随机生成的JS和JS注释拼装出一个Token和留言一起提交,在服务器端检查Token是否合法,因为Token是混在大量JS脚本中的,一般的正则工具很难把Token分析出来,从而实现了无需输入验证码的简单人机区分。

鼠标行为探测

如果攻击者使用的是全功能浏览器,那么前面的方法不会有效,但依然有办法侦测对方是人类还是机器。鼠标移动轨迹就是一个方法。人们访问网页的时候,鼠标一般不会以固定的方式移动。对于那些不希望验证码降低用户体验的网页,可以用鼠标移动监测的方式判断是否人类用户。这一方法可以和滚动条状态等方法一起使用,对于平板电脑用户,还可以加入触屏行为监测。

当然,如果被攻击者发现了,攻击者也能写出模拟鼠标随机移动的程序,此时这一方法的效果就会打折扣了。

延迟业务自动开启

我们被攻击的地方是浏览器自动弹出的客服聊天窗口,考虑到自动机攻击时页面开启时间比较短,而正常用户页面开启时间比较长,可以延迟自动聊天窗口的弹出,绕过攻击。

按地区过滤非主流用户

这个方法是结合具体业务而设计的。由于被攻击的业务集中在临近的几个省,可以利用IP地理位置库设置规则,只放行主要业务地区的线上来访请求,其它地区则转向线下电话咨询。

SQLite3.7.11以上版本才支持一条INSERT语句插入多行数据

最近被SQLite坑了:一个在Ubuntu14.04的节点上运行正常的PHP程序迁移到CentOS 6.4的服务器之后就一直报SQL Syntax Error。出错的是一条批量插入数据的INSERT语句:

INSERT INTO table (col1, col2) VALUES
  ('row1col1', 'row1col2'),
  ('row2col1', 'row2col2');

报错信息也非常简单:

PHP Warning:  SQLite3::exec(): near ",": syntax error in /tmp/test.php on line 32

这里不得不吐槽一下SQLite的报错信息,你这是在逗我呢吧,我语句里逗号那么多,谁知道你说的哪一个!!相比之下MySQL的报错就友善得多,会把出错的位置之后的SQL一起打出来,一眼就能定位到出错的地方。

言归正传,原来SQLite还真是够Lite的,在3.7.11版之前压根儿不支持一条语句多行插入1。Ubuntu14.04上自带的SQLite版本是3.8.2,而CentOS 6.4上自带的仅是3.6.20。而SQLite官网上的文档也是够Lite的2,虽然一个语法图无比清晰明了,但是却完全无法体现出版本兼容性的差异。不求你像MySQL一般为每个版本写一份文档,但也起码在每篇后面加几条Hint吧?

无奈人家就是要Lite,只好自己写Blog做笔记。


  1. http://stackoverflow.com/questions/1609637/is-it-possible-to-insert-multiple-rows-at-a-time-in-an-sqlite-database/1609688#1609688 

  2. http://sqlite.org/lang_insert.html 

吐槽微信的AES加密设计

微信公众号平台的消息可以使用AES加密,他们在技术文档中说明,他们使用了256位AES加密算法,并按pkcs#7标准对加密数据进行补位,也提供了官方的PHP版加解密库程序。

我浏览了一下他们提供的PHP程序,不得不吐槽一下这个DEMO的开发者。看代码的感觉完全是一个非PHP程序员一边查着手册一边写出来的。代码里有不少低效率的写法和实现,所以我决定要重写一遍他们的加解密库。

考虑到OpenSSL库实现的AES性能是mcrypt库的好几倍,所以我优先考虑基于OpenSSL函数来开发。结果问题就出来了,AES加密算法规定的block-size是128位,不是整数倍block-size的数据需要先补位才能进行AES加密。腾讯选择pkcs#7规范进行补位我没意见,可是你定义的最大补位长度为什么是32字节?按理说最大补位长度应该和AES的block-size一样,128位应该是16字节才对呀。腾讯的这个奇怪规定直接导致无法使用OpenSSL库内置的补位支持。PHP的OpenSSL函数库到PHP5.4版以上才加入了0补位的选项参数,这才能实现自定义补位,这直接导致我的代码无法兼容PHP5.3以前的版本。

我把微信公众号开发用到的库整理出来,放到github上,目前先有这个加解密库,后续准备把微信的Web Service API接口也封装一下,逐渐发布。项目地址: https://github.com/SyuTingSong/php-wechat

理解PHP的AES加密

这是一篇翻译的文章,原文在这里。为排版效果,有改动。

理解PHP的AES加密

这一段PHP代码给出了PHP的AES加密的一个基本轮廓

首先要了解的东西是下面几个常量:

MCRYPT_RIJNDAEL_128
MCRYPT_RIJNDAEL_192
MCRYPT_RIJNDAEL_256

你可能认为MCRYPT_RIJNDAEL_256意味着256位加密技术,但其实不是的。这三个选项定义的是Rijndael加密过程中所使用的块尺寸(block-size),而与密钥长度(即所谓的加密强度)完全无关。(后面会解释AES加密中的加密强度如何设置。)

Rijndael是一种块加密算法,它在一系列互不相关的数据块上分别进行操作。所以必须对数据进行补位操作,以保证被加密的数据长度正好是块尺寸的整数倍。(PHP使用NULL字节进行补位) 因此,如果你指定了MCRYPT_RIJNDAEL_256,你加密输出的数据长度一定是32字节(256位)的整数倍;如果你指定了MCRYPT_RIJNDAEL_128,你的加密输出则一定是16字节的整数倍。

注意:严格地说,AES和Rijndael并不完全等价(尽管实践中它们经常互换使用),因为Rijndael支持更大范围的块尺寸和密钥长度;AES使用固定的128位块尺寸,而密钥长度可以是128位、192位或者256位。Rijndael的块尺寸和密钥长度只要求使用32位的整数倍,最小128位,最大256位。

简而言之:如果你想和AES兼容,请永远使用MCRYPT_RIJNDAEL_128。

那么,第一步自然是初始化加密器对象

$cipher = mcrypt_module_open(MCRYPT_RIJNDAEL_128, '', MCRYPT_MODE_CBC, '');

我们使用CBC模式(cipher-block chaining)进行加密。块加密模式详见这里。要使用CBC模式,需要提供一个初始向量(IV)。初始向量的长度必须和块尺寸相当(不一定等于密钥长度)。既然我们的块尺寸是128位,所以IV也应该是128位(16字节)。也就是说,对于AES加密方法,初始向量总是16字节,不论加密强度如何。

这里是验证初始向量长度的代码:

$iv_size = mcrypt_enc_get_iv_size($cipher);
printf("iv_size = %d\n",$iv_size);

那么在PHP中,如何进行256位而不是128位的AES加密?答案是使用32字节长的密钥。

譬如:

$key256 = '12345678901234561234567890123456';
$key128 = '1234567890123456';

我们的128位初始向量既可以用于128位加密,又可以用于256位加密。

$iv =  '1234567890123456';

printf("iv: %s\n",bin2hex($iv));
printf("key256: %s\n",bin2hex($key256));
printf("key128: %s\n",bin2hex($key128));

这是要加密的原文:

$cleartext = 'The quick brown fox jumped over the lazy dog';
printf("plainText: %s\n\n",$cleartext);

mcrypt_generic_init函数初始化的时候,需要同时提供密钥和初始向量。密钥的长度决定了我们是在进行128位、192位还是256位加密。这里我们先进行256位加密:

if (mcrypt_generic_init($cipher, $key256, $iv) != -1)
{
    // 如果$cleartext的长度并非恰好是块尺寸的整数倍,PHP会用NULL字符进行补位
    $cipherText = mcrypt_generic($cipher,$cleartext );
    mcrypt_generic_deinit($cipher);

    // 以十六进制显示最终结果:
    printf("256-bit encrypted result:\n%s\n\n",bin2hex($cipherText));
}

这次我们进行128位加密:

if (mcrypt_generic_init($cipher, $key128, $iv) != -1)
{
    // 如果$cleartext的长度并非恰好是块尺寸的整数倍,PHP会用NULL字符进行补位
    $cipherText = mcrypt_generic($cipher,$cleartext );
    mcrypt_generic_deinit($cipher);

    // 以十六进制显示最终结果:
    printf("128-bit encrypted result:\n%s\n\n",bin2hex($cipherText));
}

结果

你可以用下列测试来指导针对你自己的AES实现进行的测试

256位密钥CBC模式

IV = '1234567890123456'
 (hex: 31323334353637383930313233343536)
Key = '12345678901234561234567890123456'
 (hex: 3132333435363738393031323334353631323334353637383930313233343536)
PlainText:
 'The quick brown fox jumped over the lazy dog'
CipherText(hex):
 2fddc3abec692e1572d9b7d629172a05caf230bc7c8fd2d26ccfd65f9c54526984f7cb1c4326ef058cd7bee3967299e3

128位密钥CBC模式

IV = '1234567890123456'
 (hex: 31323334353637383930313233343536)
Key = '1234567890123456'
 (hex: 31323334353637383930313233343536)
PlainText:
 'The quick brown fox jumped over the lazy dog'
CipherText(hex):
 f78176ae8dfe84578529208d30f446bbb29a64dc388b5c0b63140a4f316b3f341fe7d3b1a3cc5113c81ef8dd714a1c99

为什么Wireshark无法解开使用Diffie-Hellman加密的SSL数据

前段时间升级了服务器的OpenSSL库,今天突然发现使用Wireshark已经无法解密https内容。查了一下资料才知道这是因为新版OpenSSL支持了Diffie-Hellman系列加密算法导致的。我的抓到的数据显示,密钥交换的过程使用的是一种叫ECDH的密钥交换算法。

以前的SSL通信,都是在客户端验证完服务器的证书值得信赖之后,产生随机密钥并直接用服务器的公钥加密发送出去,所以在向Wireshark提供了服务器的私钥之后,Wireshark能用私钥解开网络上传输的客户端生成的密钥,并用它解后续的所有SSL数据。然而用DH算法交换的密钥根本就不会在网络上传输,所以Wireshark自然无法解密了。

下面是从维基百科查到的一个简单版本的DH密钥交换过程:

  1. 一方选择质数p=23和基数g=5发送给另一方,这两个数可以公开明文传输;
  2. 甲方生成私钥a=6,并计算公钥A = ga mod p = 56 mod 23 = 8;
  3. 乙方生成私钥b=15,并计算公钥B = gb mod p = 515 mod 23 = 19;
  4. 甲乙双方互换公钥;
  5. 甲方计算s = Ba mod p = 196 mod 23 = 2;
  6. 乙方计算s = Ab mod p = 815 mod 23 = 2;
  7. 最后双方的共同计算结果2就是接下来进行对称加密传输的密钥。

Wireshark作为中间人,即使以服务器的SSL私钥解密,取得了p、g、A、B,在不知道a、b任一数值的情况下,也难以计算出s是多少。现实情况中,双方选用的p、g、a、b都会非常大,以确保难以被攻破。

要想在Wireshark上进行debug,只能要么在服务器禁用DH系的所有加密算法,要么干脆直接先用明文协议开发,完成后再接入SSL。