I. Tổng quan về CommonsCollections2 chain
Chain này có tác dụng là RCE.
Để hiểu và phân tích chain này cần có kiến thức về các cơ chế sau trong Java:
- Reflection API
- Javasisst (Java Assist)
CC1 có thể sử dụng 1 trong 2 method để khai thác:
- Một là sử dụng
LazyMap.get(), lúc này tổng thể chain sẽ trông như sau:
1 | ObjectInputStream.readObject() |
- Hai là sử dụng
TransformedMap.put():
1 | ObjectInputStream.readObject() |
Trong bài này tôi sẽ phân tích theo thứ tự Sink -> Source -> Gadget Chain.
Hai cách sử dụng chain đa phần đều giống nhau, chỉ khác ở 1 cái sử dụng LazyMap.get(), 1 cái sử dụng TransformedMap.put() do đó khi phân tích phần Sink và phần Source tôi sẽ để ở mục chung, còn phân tích về 2 gadget nhỏ trong chain tôi sẽ tách riêng
II. Setup môi trường
Github: https://github.com/frohoff/ysoserial
JDK 8u65
Maven 3.5.1
Nếu muốn đặt Language Level là 8 - Lamdas, type anotations, etc. thì ta phải sử dòng 25-26 trong file pom.xml của project
III. Phân tích chain
Ở đây tạm thời sử dụng PoC có gadget chain dùng LazyMap.get() chủ yếu để xem full chain để nhìn qua overview là chính, còn về bản chất vẫn có 2 PoC dành riêng cho LazyMap và TransformedMap
1 | import org.apache.commons.collections.Transformer; |
3.1. Phân tích phần Sink
Phân tích đoạn này trước, vì đây là Sink để ta có thể thực hiện RCE:
1 | Transformer[] transformers = new Transformer[]{ |
Transformer là một interface.ConstantTransformer và InvokerTransformer đều là các class được implement từ interface Transformer.
Class ConstantTransformer
Constructor của ConstantTransformer nhận vào một object bất kỳ và lưu object đó vào biến nội bộ iConstant. Trong PoC, object được truyền vào là Runtime.class.
Sau đó, mỗi khi phương thức transform() được gọi, nó sẽ bỏ qua tham số đầu vào của transform() và luôn trả về object đã được lưu trong iConstant, tức là Runtime.class.
Nói cách khác, dù transform() được gọi với bất kỳ giá trị nào, kết quả trả về vẫn luôn là giá trị đã truyền vào constructor ban đầu.
Ví dụ minh họa ngắn:
1 | Transformer transformer = new ConstantTransformer(Runtime.class); |
Cả 3 kết quả đều là: Runtime.class
Chúng ta sẽ đi sâu hơn vào chi tiết chính xác bằng cách nào phương thức transform() được gọi ở trong gadget chain sau.
Class InvokerTransformer
Đặt một breakpoint và trace vào class InvokerTransformer bằng debugger.
Ở đây, ba tham số được truyền vào constructor:
- Tên phương thức (method name)
- Kiểu của các tham số (parameter types – tức là chữ ký của phương thức)
- Giá trị tham số thực tế (actual argument values)
Để dễ hiểu hơn, dưới đây là phân tích các giá trị tham số được truyền vào cho từng InvokerTransformer:
getMethod,null,"getRuntime"invoke,null,nullexec,null,"calc.exe"
Class này cũng chứa một method transform, đây là một phần rất quan trọng trong chain - tuy nhiên, hiện tại chúng ta sẽ tạm hoãn việc phân tích chi tiết phương thức này và sẽ quay lại sau.
Class ChainedTransformer
Hãy xem dòng code tiếp theo:
1 | Transformer transformerChain = new ChainedTransformer(transformers); |
Ở đây, mảng transformers được truyền vào constructor của ChainedTransformer.
Constructor ChainedTransformer sẽ gán mảng transformers được truyền vào cho một biến thành viên của lớp.
Sau này, nếu phương thức transform() được gọi, nó sẽ duyệt qua mảng transformer và lần lượt gọi phương thức transform() của từng transformer.
Vì vậy, tại thời điểm này trong quá trình phân tích, câu hỏi quan trọng cần làm rõ là:
- Chính xác thì khi nào phương thức
transform()củaChainedTransformerđược gọi?
Mô tả chính thức (sau khi được dịch):
Một implementation của transformer cho phép xâu chuỗi (chain) nhiều transformer lại với nhau.
Đối tượng đầu vào sẽ được truyền vào transformer đầu tiên, kết quả của transformer đó sẽ được truyền cho transformer thứ hai, và cứ tiếp tục như vậy.
Nói cách khác, output của Transformer trước sẽ trở thành input của phép Transformer tiếp theo, cho đến khi kết thúc toàn bộ chain.
Ở đây chúng ta thấy rằng Runtime được truyền vào, nhưng tại sao lại cụ thể là Runtime?
Nếu quay lại xem class ConstantTransformer, ta có thể thấy rằng khi phương thức transform() của nó được gọi, nó chỉ đơn giản trả về this.iConstant.
Trước đó, khi chúng ta định nghĩa mảng transformer, chúng ta đã khởi tạo nó bằng: new ConstantTransformer(Runtime.class)
Ở đây có thể thấy, bên trong mảng Transformer[], thứ được lưu là Runtime.class, chứ không phải Runtime object.
Nguyên nhân là vì Runtime không implement interface java.io.Serializable, nên nó không thể bị serialize.
Còn Runtime.class thuộc về java.lang.Class. java.lang.Class có implement interface java.io.Serializable, nên nó có thể bị serialize.
Vì vậy, đối tượng được truyền vào ở đây chính là đối tượng lớp Runtime (Runtime.class).
Điều này có nghĩa là trong lần thực thi đầu tiên của chuỗi biến đổi (transformation chain), Runtime.class đã được truyền làm đầu vào cho toàn bộ chuỗi.
Phương thức transform() ở đây sử dụng Java Reflection:
1 | Class cls = input.getClass(); |
Đoạn code này sử dụng reflection để gọi một phương thức và trả về kết quả của lời gọi getRuntime().
Nói ngắn gọn, nó sẽ:
- Thực hiện reflection trên đối tượng đầu vào (
input) - Tìm phương thức có tên được chỉ định bởi
getMethod() - Gọi (invoke) phương thức đó với tham số
getRuntime() - Và trả về kết quả của lời gọi phương thức đó
Ở lần thứ hai, giá trị đầu vào (input) được truyền vào là Runtime.getRuntime()
1 | Class cls = input.getClass(); |
Ở lần thứ hai, phương thức này trả về đối tượng Runtime đã được khởi tạo.
Sau đó, ở lần thứ ba, instance Runtime này sẽ được truyền vào như tham số cho lời gọi phương thức tiếp theo.
1 | Class cls = input.getClass(); |
Bằng cách này, một lệnh hệ thống đã được thực thi thành công.
Mục đích của ChainedTransformer là xâu chuỗi (chain) các Transformer lại với nhau.
Tóm tắt:
1 | Transformer[] transformers = new Transformer[] { |
Phần code bên trên tương đương với: Runtime.class.getMethod("getRuntime").invoke(null).exec("calc.exe");
ChainedTransformer.transform():
Khi transformerChain.transform(...) được gọi, nó sẽ thực thi exec("calc.exe").
Nhưng làm thế nào để trigger được transformerChain.transform()
Class TransformedMap và class LazyMap
Đây chính là 2 class giúp trigger phương thức transform(). Do 2 Map (TransformedMap và LazyMap) này là wrapper của Map gốc, nên khi có thao tác trên Map gốc chẳng hạn như put() hoặc setValue() của Map.Entry thì method trên 2 Map được biến đổi sẽ được thực thi
Với TransformedMap.
3.2.1. Phân tích chain sử dụng TransformedMap
PoC sử dụng TransformedMap
1 | import org.apache.commons.collections.Transformer; |
Ở trên ta đã nói về việc TransformedMap dùng để trigger method transform(). Xem xét bên trong class TransformedMap chứa gì?
Ta thấy method checkSetValue() có gọi đến transform(). Tuy nhiên ta cần xem valueTransformer là gì ?
Ta thấy valueTransformer là một field bên trong constructor của TransformedMap:
Nói qua tổng thể về constructor của TransformedMap.
- Nó có modifier là
protected - Nó nhận vào 3 tham số, trong đó:
- 1 tham số kiểu
Map - 2 tham số kiểu
Transformer(1 để lưu key cần biến đổi, 2 là lưu value cần biến đổi)
- 1 tham số kiểu
Dòng super(map); thực chất truyền map lên class cha là AbstractInputCheckedMapDecorator. Ta có thể hover vào chữ super rồi ấn Ctrl + B để tìm class parent.
Do constructor TransformedMap có modifier là protected nên ta phải dùng method TransformedMap.decorate() để khởi tạo 1 instance của TransformedMap.
Sở dĩ việc để constructor của
TransformedMapcó modifier làprotectedlà doTransformedMapko được thiết kế để tự tạo ra 1 instance mới, mà nó chỉ đơn thuần để trang trídecoratecho 1 instance của 1 Map khác.
Trong exploit, đây là đoạn dùng để khởi tạo 1 instance của TransformedMap (là biến outerMap), trong đó truyền cả transformerChain vào vị trí mà valueTransformer để làm bàn đạp thực thi method transform
1 | Map innerMap = new HashMap(); |
Tới đây thì TransformedMap đã đc khởi tạo, tuy nhiên ta cần tìm method gọi đến TransformedMap.checkSetValue().
Ấn Ctrl + Shift + F và tìm từ khóa checkSetValue():
Ở đây tôi tìm được method MapEntry.setValue() có gọi đến checkSetValue(). MapEntry là 1 inner class nằm trong trong class AbstractInputCheckedMapDecorator.
Về setValue(), method này đơn giản chỉ là lưu giá trị vào phần value trong cặp “key - value” của 1 Map bình thường.
Ta có thể làm như sau để kiểm chứng:
Ấn vào biểu tượng “@ màu xanh” để xem MapEntry.setValue() đang Override lại method nào
Ta thấy nó override method setValue() của AbstractMapEntryDecorator() (thật ra ngay từ khi nhìn thấy phần MapEntry extends AbstractMapEntryDecorator ta đã có thể thấy rồi). Tiếp tục, lại ấn vào biểu tượng để xem nó implement từ method gốc nào.
Ta có thể thấy nó implement method setValue() của interface Map
Vậy ở ta cần tìm nơi gọi đến setValue(). Ta thấy trong method readObject() của class AnnotationInvocationHandler có gọi đến setValue()
Ở đây đã ta đã trace đến phần source của chain này là class AnnotationInvocationHandler nên chuyển sang mục tiếp theo để phân tích tiếp
3.2.2. Phân tích chain sử dụng LazyMap
PoC sử dụng LazyMap:
1 | import org.apache.commons.collections.Transformer; |
Ở trên ta đã nói về việc LazyMap dùng để trigger method transform(). Xem xét bên trong class LazyMap chứa gì?
Ta thấy method get() có gọi đến transform() ở đoạn factory.transform(key). Tuy nhiên ta cần xem factory là gì ?
factory cũng có kiểu dữ liệu Transformer
Constructor của LazyMap cũng có modifier là protected.
Ngoài ra LazyMap cũng có method decorate() với tác dụng tương tự với decorate() của TransformedMap, đều là wrapper của Map.
Ta khởi tạo LazyMap thông qua decorate()
1 | Map innerMap = new HashMap(); |
Ở đây cách hoạt động cũng tượng tự như việc dùng TransformedMap để bọc Map, tôi sẽ ko phân tích lại.
Phần này ta quan tâm chủ yếu xem method nào đã gọi LazyMap.get(), hay hiểu đơn giản là gọi method get() trên 1 Map
Bên trong method invoke() của AnnotationInvocationHandler có gọi đến method this.memberValues.get()
mà this.memberValues có kiểu dữ liệu là 1 Map
Đi qua phần phân tích Source để xem cách AnnotationInvocationHandler thực thi khi dùng với LazyMap
3.3. Phân tích phần Source
Bình thường nếu class AnnotationInvocationHandler public, ta có thể dùng AnnotationInvocationHandler.class. Tuy nhiên do nó có modifier là default và nằm trong package nội bộ của JDK sun.reflect.annotation nên ta phải dùng Reflection để instantiate.
1 | Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); |
Việc khởi tạo 1 instance của AnnotationInvocationHandler đã xong.
Ở đây ta thấy để có thể gọi được đến var5.setValue() thì phải thỏa mãn được vài điều kiện.
Ở đây ta sẽ đi lần lượt qua cách hoạt động của AnnotationInvocationHandler.readObject() cũng như cách bypass các điều kiện.
Trước tiên, dòng var1.defaultReadObject(); sẽ thực hiện deserialize các field của object ở chế độ mặc định.
Ví dụ trong AnnotationInvocationHandler ta biết khi khởi tạo có các field như sau:
1 | private final Class<? extends Annotation> type; |
Sau dòng này, object sẽ khôi phục lại được sẽ là: this.type và this.memberValues. Giả sử: this.type = Retention.class; và this.memberValues = một Map nào đó
Đến đây ta thấy xuất hiện điều kiện đầu tiên:
1 | try { |
this.type là Class<?> đại diện cho kiểu annotation. Nếu chưa rõ Class<?> là gì có thể xem lại bài (TODO).
this.type kiểm tra có thật sự là một annotation type hợp lệ không, đồng thời lấy metadata của annotation đó và lưu vào var10.
Một annotation type hợp lệ nếu nó nằm trong là 1 trong số các type sau: Retention.class, Target.class, Override.class, Deprecated.class
Các type sau đc cho là ko hợp lệ: String.class, HashMap.class, Runtime.class
Đi tiếp tới dòng Map var3 = var10.memberTypes();, dòng này lấy ra map chứa tên member của annotation và kiểu dữ liệu tương ứng rồi lưu vào var3
Ví dụ về cách var3 lưu dữ liệu. Ta có annotation sau:
1 | MyAnno { |
Trong Java, member của annotation được khai báo giống method, nhưng ý nghĩa của nó là một “thuộc tính” của annotation. Nhìn
name()vàage()giống hàm vì cú pháp Java dùng method declaration để định nghĩa annotation element.
Ta dùng annotation:
1 |
|
Lúc này "Nam" là giá trị cho name(), còn 16 là giá trị cho age().
Vậy memberTypes() sẽ có giá trị:
1 | { |
Tiếp tục, ta đi đến đoạn code chứa 2 điều kiện cuối cùng cần thỏa mãn để có thể đi được vào method setValue()
1 | for(Map.Entry var5 : this.memberValues.entrySet()) { |
Dòng này dùng để duyệt qua từng entry trong memberValues.
1 | for (Map.Entry var5 : this.memberValues.entrySet()) { |
Ví dụ:
1 |
vậy var5 sẽ có dạng:
1 | { |
Tiếp tục, đến dòng String var6 = (String)var5.getKey(); sẽ lấy key của entry, ép kiểu sang String. Key là tên member annotation, ví dụ: "name", "age".
Dòng Class var7 = (Class)var3.get(var6); sẽ lấy kiểu dữ liệu được khai báo của member có tên var6.
Như phân tích ở bên trên ta thấy var3 có kiểu dữ liệu như sau:
1 | { |
Vậy thì var7 sẽ có giá trị như sau:
- Nếu
var6là"name"thìvar7là:String.class - Nếu
var6là"age"thìvar7là:int.class - Nếu không tìm thấy member tương ứng,
var7sẽ lànull.
Đi tiếp ta gặp điều kiện thứ hai:
1 | if (var7 != null) { |
Ta biết var3 chỉ lấy memberTypes có trong var10, mà số lượng member sẽ bị giới hạn đối với từng loại Annotation.
var5 lấy dữ liệu từ memberValues, nên nếu memberValues có key không tồn tại trong annotation thì var3.get(key) sẽ trả về null.
Vậy thì khối if này sẽ chỉ thực hiện xử lý đối với các cặp key - value mà phần key có xuất hiện trong annotation được khai báo, còn ko thì bỏ qua.
Muốn đi qua điều kiện if (var7 != null) thì memberValues phải có ít nhất 1 key trùng với member được khai báo trong annotation.
Ví dụ hợp lệ:
1 | MyAnno { |
Lúc này nếu memberValues như sau sẽ pass
1 | memberValues = { |
Nhưng nếu memberValues như sau sẽ fail
1 | memberValues = { |
Ta có thể dùng Retention để làm Annotation, sở dĩ do annotation này có kiểu dữ liệu là string, nếu ta truyền 1 hashmap có key là string thì điều kiện này có thể thỏa mãn.
1 | Map innerMap = new HashMap(); |
Ta truyền vào annotation type là Retention.class, annotation này thỏa mãn điều kiện có ít nhất 1 memberValues. Như vậy đủ để pass đoạn for(Map.Entry var5 : this.memberValues.entrySet()).
3.3.1. Cách AnnotationInvocationHandler thực thi khi dùng với TransformedMap
Ở đoạn này:
1 | Map innerMap = new HashMap(); |
Ta truyền memberValues là 1 HashMap với 1 key có giá trị là value. Điều này giúp thỏa mãn điều kiện if (var7 != null). Tuy rằng HashMap này đã được decorate bởi TransformedMap nhưng ko sao.
Ok, vậy là đã thỏa mãn 2 điều kiện đầu tiên. Ta đi phân tích nốt đoạn cuối để dẫn đến được phần gọi đến setValue()
Dòng Object var8 = var5.getValue(); lấy giá trị thực tế của member.
Ta nhớ var5 là 1 Map với kiểu dữ liệu:
1 | { |
Lúc này var8 sẽ có giá trị: {"An", 16}
Đến với điều kiện cuối:
1 | if (!var7.isInstance(var8) && !(var8 instanceof ExceptionProxy)) |
Khối if này kiểm tra giá trị có đúng kiểu mong đợi không.
Điều kiện này có 2 phần:
Phần !var7.isInstance(var8) nghĩa là var8 không phải instance của kiểu var7. Ví dụ member cần String nhưng giá trị lại là Integer thì sai.
Phần !(var8 instanceof ExceptionProxy) nghĩa là var8 cũng không phải object đại diện cho lỗi annotation.
ExceptionProxylà cơ chế trì hoãn lỗi. Thay vì throw lỗi ngay lúc deserialize, Java có thể lưu một proxy lỗi và chỉ throw lỗi khi người dùng gọi member annotation đó.
Toàn bộ điều kiện nghĩa là:
Nếu giá trị không đúng kiểu, và nó cũng chưa phải là proxy lỗi, thì thay nó bằng một proxy lỗi.
Ở đây ta đã đến được var5.setValue(), tuy nhiên ta lại ko thể control được tham số truyền vào setValue() vì đã bị chỉ định là class AnnotationTypeMismatchExceptionProxy.
Ta cần tìm một class có thể kiểm soát được tham số của setValue().
Với exploit của chúng ta:
1 | Map innerMap = new HashMap(); |
Khi code chạy:
1 | for (Map.Entry var5 : this.memberValues.entrySet()) |
Lúc này this.memberValues = outerMap, nhưng vì outerMap là TransformedMap chứ ko phải HashMap như innerMap.
Khi code thực thi 1 hành động trên Map, cụ thể là var5.setValue() thì lúc này sẽ ko thực thi ngay đoạn var5.setValue() mà sẽ chạy qua logic của wrapper (tức TransformedMap).
Vậy thì khi var5.setValue(x) được gọi thì:
1 | Object transformed = transformerChain.transform(x); |
Với x là tham số đc truyền vào var5.setValue():
x = new AnnotationTypeMismatchExceptionProxy(...).setMember(...)originalEntrylà entry bên tronginnerMap, ví dụ entry"value" -> "value"
Vậy thì điều này liên quan gì đến việc tìm ra method có thể control được tham số bên trong setValue() ?
Câu trả lời ở đây là khi ta sử dụng ConstantTransformer làm phần tử đầu tiên của array transformerChain, lúc này sau lần transform() đầu tiên nó sẽ luôn trả về giá trị mà ta đã truyền vào khi khởi tạo ConstantTransformer. Vậy thì x ở trong đoạn Object transformed = transformerChain.transform(x); sau lần transform() đầu tiên sẽ luôn trả về cùng 1 giá trị, mà giá trị này ta kiểm soát được.
Khi tôi phân tích cách dùng với LazyMap.get() tôi chỉ nghĩ rằng ConstantTransformer được dùng để làm bàn đạp đưa Runtime.class vào, tuy nhiên khi phân tích cách xây dựng chain với TransformedMap tôi mới nhận ra nó có tác dụng khác là như vậy.
Vậy là đã phân tích xong cách xây dựng chain với TransformedMap.
3.3.2. Cách AnnotationInvocationHandler thực thi khi dùng với LazyMap
Bản chất đoạn:
1 | Map innerMap = new HashMap(); |
cũng tương tự với đoạn
1 | Map innerMap = new HashMap(); |
Tuy nhiên có 1 điểm khác biệt, khi dùng TransformedMap ta sẽ trigger ngay sau khi hàm readObject() của AnnotationInvocationHandler thực thi và đi đến được method var5.setValue().
Với LazyMap thì phần trigger lại là method invoke() của AnnotationInvocationHandler, do đó ta cần tìm cách gọi được đến invoke(). Quá trình gọi tới invoke() chính là nhờ sự tận dụng cơ chế Dynamic Proxy trong Java.
Về Dynamic Proxy, đọc bài viết sau: TODO. Ở dưới tôi sẽ phân tích cách mà dynamic proxy được tận dụng để gọi tới invoke() và làm bàn đạp trigger chain.
Xem đoạn code sau:
1 | Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); |
Ở đây ta thấy có tận 2 handler:
InvocationHandler handler = (InvocationHandler) constructor.newInstance(Retention.class, outerMap);handler = (InvocationHandler) constructor.newInstance(Retention.class, proxyMap);
Chain này mới đầu khi tôi đọc thì thấy phải tạo 2 lần handler của Dynamic Proxy thì cảm giác hơi rối, không hiểu vì sao phải tạo như vậy.
Tác dụng của handler thứ nhất:
Handler thứ nhất dùng để bọc LazyMap
1 | InvocationHandler handler = (InvocationHandler) construct.newInstance(Retention.class, outerMap); |
Lúc này ta có:
1 | handler_1 |
handler_1 là một AnnotationInvocationHandler.
Khi handler_1.invoke(...) được gọi bởi dynamic proxy, nó sẽ lấy tên method rồi gọi vào memberValues.get(methodName).
Nếu proxy được gọi: proxyMap.entrySet(); thì bên trong handler_1.invoke(...) sẽ thực hiện kiểu:
1 | String methodName = "entrySet"; |
Điều này đồng nghĩa với việc thực thi outerMap.get("entrySet");, đây chính là đoạn gọi tới LazyMap.
Tạo dynamic proxy proxyMap
1 | Map proxyMap = (Map) Proxy.newProxyInstance( |
Lúc này ta có:
1 | proxyMap là Proxy implement Map |
Nếu ai đó gọi: proxyMap.entrySet(); thực tế không chạy HashMap.entrySet() bình thường. Mà nó sẽ chuyển thành: handler_1.invoke(proxyMap, method entrySet, args) rồi handler_1 sẽ gọi outerMap.get("entrySet");
Từ đó kích hoạt transformerChain.
Tạo handler thứ hai: handler bọc proxyMap
1 | handler = (InvocationHandler) construct.newInstance(Retention.class, proxyMap); |
Đây là handler thứ hai.
Lúc này cấu trúc trở thành:
1 | handler_2 |
Sau đó serialize handler_2:
1 | oos.writeObject(handler); |
Tức object chính được ghi ra file là handler_2, không phải handler_1.
Nói tóm lại:
- Khi bắt đầu
AnnotationInvocationHandlergọi đếnthis.memberValues.entrySet()vớimemberValueslàproxyMap, nhưng khi gọi tớiproxyMapthì method call lại đổi hướng, lúc này gọi tớiAnnotationInvocationHandler.invoke(). - Bên trong
AnnotationInvocationHandler.invoke()gọi đếnObject var6 = this.memberValues.get(var4);vớimemberValueslúc này làLazyMap
Vậy là đã phân tích xong cách xây dựng chain với LazyMap.