This page looks best with JavaScript enabled

Khi newbie học Java deserialization attack: readObject() native

 ·  ☕ 12 min read  ·  🐉 Edisc

Khi newbie học Java deserialization attack: readObject() native


I. Lời mở đầu

  • Sau hơn 2 tháng học về PHP deserilazation, tôi đã chuyển qua học Java deserialization với tham vọng sẽ rèn một loại lỗi Insecure deserialization trên các ngôn ngữ PHP, JAVA, .Net 🤭🤭🤭🤭🤭. Vì là một Java deserialize fan tháng 8 nên mọi thứ bắt đầu hầu như là số 0. Và phương pháp tiếp cận ở đây sẽ là đi từ đơn giản đến nâng cao thông qua rèn luyện nhiều hơn là đọc lý thuyết.

  • Tôi bắt đầu với hàm sink quen thuộc của Java Deserialization là readObject(), sau đó là các chain exploit đã có sẵn trên Apache Commons Collection để hiểu rõ các kiến thức cơ bản cùng các kĩ năng cơ bản về debug, setup môi trường. Và bây giờ đang trong giai đoạn vận dụng để luyện tập trên weblogic. Một lộ trình khá rõ ràng.

  • Tôi kết thúc học tập về chain exploit Apache Commons Collection với một mindmap như sau:

    Untitled

  • Vì dạo gần đây cũng có vài task liên quan về việc present và viết blog để improve kĩ năng, nên tôi cũng tận dụng để viết luôn, với hai mục tiêu chính:

    1. Ôn lại và hiểu rõ một cách thấu đáo hơn những đơn vị kiến thức đã học. Đồng thời nâng cao các kĩ năng mềm khác.
    2. Là nơi để lưu trữ kiến thức và khi cần tôi có thể lấy ra đọc, ôn lại
  • Như tiêu đề của bài, chuỗi blog này sẽ viết về hành trình học Java Deserialization của một newbie. Do đó, trong blog sẽ không có những “yếu tố mới lạ” hay những “kiến thức cao siêu”. Hi vọng các “siêu nhân” có tình cờ ghé qua thì đừng đặt kì vọng quá nhiều!

II. Java Deserialization 101

Câu hỏi đầu tiên mà newbie nào khi tiếp cận chủ đề này chắc hẳn: Deserialization là gì? Như thế nào là Insecure deserialization?

  • Xuất phát từ việc các ứng dụng giao tiếp, truyền dữ liệu dưới dạng byte.

    Untitled

  • Hiện nay một ứng dụng điển hình sẽ có nhiều thành phần và sẽ được phân phối trên các hệ thống và mạng khác nhau. Trong Java, mọi thứ được biểu diễn dưới dạng các Object. Nếu hai thành phần Java muốn giao tiếp với nhau, cần phải có một cơ chế để trao đổi dữ liệu. Mỗi đối tượng trong Java sẽ có các kiểu đối tượng khác nhau gây khó khăn trong việc trao đổi dữ liệu. Do đó, cần phải có một giao thức chung chung và hiệu quả để chuyển đối tượng giữa các thành phần.

Serialization được định nghĩa cho mục đích này, và các thành phần Java sử dụng giao thức này để chuyển các đối tượng

Untitled

Thông qua hình ảnh minh họa ở trên, ta có thể hiểu

Serialization là quá trình chuyển Object thành ByteStream. Deserialization là quá trình ngược với Serialization.

  • Insecure Deserialization là khi dữ liệu do người dùng kiểm soát được deserialize bởi một trang web. Điều này có khả năng cho phép kẻ tấn công thao túng các đối tượng được serialize để truyền dữ liệu có hại vào code ứng dụng. Thậm chí có thể thay thế một đối tượng được serialize bằng một đối tượng của một lớp hoàn toàn khác. Một điểm cần lưu ý ở đây, các đối tượng của bất kỳ lớp nào có sẵn cho trang web sẽ được deserialize và khởi tạo. Vì lý do này, insecure deserialization đôi khi được gọi là lỗ hổng “object injection”

Untitled

  • Vì đã có kiến thức về PHP deserialization attack, nên tôi bắt đầu với một ảnh minh họa mô hình tấn công trong PHP deserialization:

Untitled

Attacker truyền vào payload serialized Object và webserver đã deserialize payload này. Điều này cho phép kẻ tấn công có thể thực thi câu lệnh

  • Về cơ bản, tôi tạm chia quá trình khai thác Java Deserialization thành 2 giai đoạn như sau:

    1. Application Vulnerability: Tại bước này, attacker phải tìm được endpoint (source) để truyền dữ liệu vào và một hàm sink thực hiện deserialize dữ liệu của attacker. Trong PHP hàm sink này là unserialize() còn trong Java thì nó là readObject().

      Untitled

    2. Exploit chain: Tại bước này, readObject() ở bước trước sẽ từ sink trở thành source và sink sẽ là mục tiêu của attacker (có thể là RCE, tạo file,…). Với newbie, chúng ta sẽ tập trung vào exploit chain để rèn luyện các kĩ năng cơ bản, cụ thể là Apache CommonsCollections

    Untitled

III. readObject() native

  • Như đã biết, readObject là hàm thực hiện deserialization trong Java. Do đó, cách tiếp cận của tôi sẽ làm 1 ví dụ cơ bản về serialization và deserialization và xem luồng thực thi của hàm này như thế nào.

  • Tôi tạo class User.java:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    
    package nativeReadObject;
    
    import java.io.IOException;
    import java.io.Serializable;
    
    public class User implements Serializable {
        private String name;
        private int age;
        @Override
        public String toString() {
            return "User{" +
                    "name = '" + name + '\'' +
                    ", age = " + age +
                    '}';
        }
    //    private void readObject(java.io.ObjectInputStream stream) throws IOException {
    //        System.out.println("ReadObject overwrite");
    //        Runtime.getRuntime().exec(String.format("cmd.exe /c calc"));
    //    }
        public String getName() {
            return name;
        }
        public void setName(String name) {
            this.name = name;
        }
        public int getAge() {
            return age;
        }
        public void setAge(int age) {
            this.age = age;
        }
        public User(){}
        public User(String name, int age) {
            this.name = name;
            this.age = age;
        }
    }
    
  • Và một class để serialize và deserialize ReadTest.java:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    
    package nativeReadObject;
    
    import java.io.*;
    
    public class ReadTest {
        public static void main(String[] args) throws IOException, ClassNotFoundException {
            User user = new User();
            user.setName("Edisc");
            user.setAge(23);
    
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.ser"));
            // serialize: Object => byte stream
            oos.writeObject(user);
    
            ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.ser"));
            // deserialize: byte stream => Object
            Object o = ois.readObject();
    
            User deserUser = (User) o;
            System.out.println(deserUser.getName());
            System.out.println(deserUser.getAge());
    
        }
    }
    
  • Trong class User.java, chúng ta chú ý đoạn code từ dòng 17-20. Khi không có dòng code này, kết quả sẽ như sau:

Untitled

  • Còn khi có đoạn code:

Untitled

  • Bạn thấy chứ? khi chúng ta implement readObject thì định nghĩa của hàm này sẽ bị overwrite và data trả về là null, 0 chứ không như default. Một lưu ý ở đây là kiểu dữ liệu private và param là java.io.ObjectInputStream stream.

Để hiểu rõ hơn, ta tiến hành debug:

  • Đầu tiên, chương trình sẽ gọi readObject() của ObjectInputStream.java

Untitled

  • Tại dòng 373, method ObjectInputStream#readObject0()sẽ được gọi

Untitled

Đoạn mã trên chịu trách nhiệm đọc các object từ input. Cụ thể:

  1. boolean oldMode = bin.getBlockDataMode();: Ở đây, biến oldMode được sử dụng để lưu trạng thái hiện tại của chế độ dữ liệu khối (block data mode) trong đối tượng bin (một đối tượng BlockDataInputStream).
  2. if (oldMode) { ... }: Nếu đang ở chế độ dữ liệu khối cũ (oldMode), một số xử lý đặc biệt được thực hiện. oldMode là một trạng thái khi đang đọc dữ liệu mà gặp một dữ liệu block không thể xử lý được ngay lập tức.
  3. byte tc;: Biến tc là một biến byte được sử dụng để lưu trữ mã nhận dạng kiểu dữ liệu hiện tại trong luồng dữ liệu.
  4. while ((tc = bin.peekByte()) == TC_RESET) { ... }: Vòng lặp này kiểm tra xem liệu có bất kỳ mã nhận dạng TC_RESET nào ở đầu luồng dữ liệu không. Nếu có, nó sẽ tiếp tục đọc dữ liệu và thực hiện các xử lý liên quan đến việc đặt lại các trạng thái của luồng.
  5. depth++;: Biến depth được sử dụng để theo dõi độ sâu của đối tượng đang được đọc. Nó tăng lên mỗi khi bắt đầu đọc một đối tượng mới.
  6. switch (tc) { ... }: Cấu trúc switch dựa vào giá trị của tc để xác định kiểu dữ liệu của đối tượng đang đọc và thực hiện xử lý tương ứng.
  7. Trong mỗi trường hợp, phương thức thực hiện xử lý tương ứng với kiểu dữ liệu đang đọc, ví dụ như đọc một chuỗi, mảng, đối tượng thường hay đối tượng được tham chiếu bởi một tham chiếu.
  8. Cuối cùng, phương thức kết thúc việc đọc đối tượng và trả về kết quả đã đọc được.
  9. finally { ... }: Phần cuối cùng của phương thức được thực hiện sau khi phương thức đã đọc xong đối tượng. Ở đây, trạng thái của luồng và biến depth được cập nhật lại để chuẩn bị cho việc đọc đối tượng tiếp theo.

Đoạn code này đảm bảo rằng các đối tượng đọc từ luồng dữ liệu đầu vào đúng định dạng và được xử lý một cách an toàn. Nó là một phần quan trọng của cơ chế đọc và giải mã đối tượng trong Java.

  • Tiếp tục chương trình, dòng lệnh 1353 được thực thi:

Untitled

  • Tại đây, ObjectInputStream#readOrdinaryObject() được gọi

Untitled

Chú ý dòng 1781 và 1782. Tại dòng 1781, hàm readClassDesc() được gọi để trả về một descriptor, giá trị lưu vào biến desc. Sau đó, biến này sẽ kiểm tra xem có thể deserialize không. Tiếp sau đó, tại dòng lệnh 1805-1809, hàm readSerialData(obj, desc) sẽ được gọi. Dễ đoán được, hàm này xử lý, chuyển đổi từ bytestream sang object.

Tôi đi vào hàm readClassDesc() để xem thử thế nào:

Untitled

Tới đây chúng ta sẽ xuất hiện một đơn vị kiến thúc mới là: Proxy và nonProxy

3.1 Proxy vs nonProxy


Bất kỳ khi nào làm việc với Java Serialization, chúng ta sẽ gặp hai khái niệm quan trọng là “proxy” và “non-proxy”. Trong ngữ cảnh Serialization, chúng thường được sử dụng để tham chiếu đến cách mà đối tượng được serialized và deserialized.

Proxy Serialization:


Khi chúng ta serialize một object theo cách proxy serialization, Java Serialization sẽ tạo một đối tượng proxy, được gọi là “proxy class”, để thay thế cho đối tượng gốc. Proxy class chứa thông tin cần thiết để tái tạo đối tượng ban đầu sau khi deserialization. Proxy class cũng được sử dụng để xác định trạng thái của đối tượng và để xử lý quyền truy cập vào trường dữ liệu của đối tượng.

Việc sử dụng proxy serialization có thể hữu ích trong một số tình huống. Ví dụ, nếu một đối tượng chứa các trường dữ liệu private, bạn có thể sử dụng proxy serialization để đảm bảo rằng chỉ các thành phần bên trong đối tượng có thể truy cập và sửa đổi dữ liệu.

Non-Proxy Serialization:


Ngược lại, khi serialization một đối tượng theo cách non-proxy serialization, Java Serialization sẽ serialize toàn bộ đối tượng, bao gồm cả dữ liệu và trạng thái của nó. Khi deserialization, đối tượng được tái tạo chính xác theo trạng thái ban đầu và không cần sử dụng proxy class.

Việc sử dụng non-proxy serialization thích hợp trong những trường hợp đơn giản, khi không có yêu cầu đặc biệt về việc kiểm soát truy cập hoặc xử lý trạng thái của đối tượng.

Trong cả hai trường hợp, proxy và non-proxy serialization đều cung cấp khả năng serialize và deserialization các đối tượng Java. Tuy nhiên, việc chọn giữa hai phương pháp này phụ thuộc vào yêu cầu và mục đích sử dụng của bạn trong việc lưu trữ và khôi phục dữ liệu đối tượng.

Tiếp tục với chương trình,

Untitled

Hàm readNonProxyDesc() sẽ được gọi,

Untitled

Trong ObjectInputStream#readNonProxyDesc(), tại dòng 1609, hàm readClassDescriptor() trả về nativeReadObject.User, lưu vào biến readDesc.

Tại dòng 1620, resolveClass() được gọi để phân giải readDesc, lưu vào biến cl.

Untitled

Đến dòng 1630, chương trình sẽ initNonProxy với các tham số readDesc, cl, resolveEx, readClassDesc(false)

Untitled

Sau đó, desc sẽ được return ở dòng 1634.

Tiếp tục với chương trình ObjectInputStream#readOrdinaryObject. Sau khi có được class descriptor trong biến desc, chương trình sẽ tiếp tục thực thi. Ở dây, ta chú ý biến obj được tạo mới thông qua desc.newInstance().Sau đó truyền vào hàm readSerialData(obj, desc)

Untitled

Hàm readSerialData

Untitled

Dòng 1899 kiểm tra xem readObject() có bị overwrite hay không. Nếu có khối lệnh 1900-1940 được gọi. Nếu không, hàm defaultReadFields() được gọi.

Điều này giải thích tại sao khi chúng ta thêm hàm private readObject() ở ví dụ đầu thì output trả về lại khác.

Vì chúng ta đang debug với ngữ cảnh có overwrite, nên

Untitled

hàm slotDesc.invokeReadObject được gọi, với object có name=null và age = 0

  • Hàm invokeReadObject() sẽ gọi readObjectMethod.invoke()

Untitled

  • Tới đây chương trình sẽ gọi tới Method#invoke()

Untitled

Method#invoke()

  • Đây là 1 Reflection API cho phép gián tiếp invoke 1 method, Reflection API này sinh ra để invoke method của 1 object không thể cast được vào 1 kiểu xác định nào đó,
  • Ví dụ:
    • Trường hợp thông thường, ta có thể khai báo HashMap obj = new HashMap(); và sau đó có thể gọi obj.put(), obj.get() bình thường mà không có vấn đề gì xảy ra.
    • Nhưng trong trường hợp thực tế, có 1 private class XYZpublic method foo(), từ một nơi nào đó, nhận được 1 (Object)object của class XYZ này.
    • Để invoke được method XYZ.foo() này, không thể gọi object.foo(), mà phải cast nó sang XYZ -> ((XYZ) object).foo() mới có thể call được.
    • Vấn đề ở đây là class XYZ được set ở private, nên từ bên ngoài không thể access được class XYZ này, và cast (XYZ) object được.

Và method reflection sinh ra để giải quyết vấn đề này,

Untitled

Theo flow của method, ma.invoke() được gọi với maDelegatingMethodAccessorImpl. Nên DelegatingMethodAccessorImpl#invoke() được gọi

Hàm invoke này sẽ gọi qua NativeMethodAccessorImpl#invoke()

Untitled

Và tới hàm NativeMethodAccessorImpl#invoke(), hàm invoke0() được gọi với 2 biến var1 là Object User và var2 là readObject method của chúng ta.

Untitled

Và tới đây nó flow sẽ nhảy về method readObject của chúng ta và popup lên 1 calc

Untitled

yeah, tới đây chúng ta đã đi hết flow của native readObject(). Tôi tổng hợp flow nằm ở sơ đồ sau:

Untitled

Trong sơ đồ trên, tôi thấy chúng ta cần hiểu rõ hàm ObjectInputStream#readOrdinaryObjectMethod#invoke vì 2 method này sẽ điều hướng luồng thực thi (khi chúng ta build payload chain)

IV. Tổng kết

  • Như đã đề cập ở phần đầu, series này là của người bắt đầu nên các đơn vị kiến thức đều là basic và blog này tôi đề cập tới đơn vị kiến thức cơ bản của cơ bản: flow của readObject()
  • Một lưu ý ở đây, để detect java serialize, chúng ta có thể thực hiện các cách sau:
    1. Java serialize object streams luôn bắt đầu với:
      • Magic bytes: 0xAC 0XED trong HEX, r00ABX trong Base64-encoded
      • Header HTTP response: Content type: application/x-javaj-serialized-object
    2. Bruteforce the input: Ysoserial by Chris Frohoff
    3. Kiểm tra các third-party product.
  • Kết thúc phần này, chúng ta sẽ có đủ kiến thức cơ bản để tiến hành nghiên cứu ApacheCommonsCollection.

V. Nguồn tham khảo

Share on

Edisc
WRITTEN BY
Edisc
Cyber Security Engineer

 
What's on this Page