JavaProgrammationDéveloppeur Java

Quel mécanisme au sein du protocole de sérialisation natif de Java permet aux attaquants d'instancier plusieurs instances d'un prétendu singleton, et quelle méthode de crochet défensive garantit le contrôle des instances lors de la désérialisation ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

Historique de la question : Java a introduit la sérialisation binaire native dans JDK 1.1 via les API ObjectOutputStream et ObjectInputStream, établissant un protocole permettant d'aplatir les graphes d'objets en flux d'octets pour la persistance ou le transfert réseau. La spécification impose qu'au cours de la reconstruction, ObjectInputStream alloue de la mémoire pour l'objet cible en utilisant sun.misc.Unsafe ou la réflexion directe, contournant complètement les constructeurs. Ce choix de conception est fondamentalement en conflit avec le modèle singleton qui repose sur des constructeurs privés pour restreindre l'instanciation.

Le problème : Lorsqu'une classe implémente Serializable, le framework de désérialisation crée une nouvelle instance en invoquant allocateInstance sans exécuter de logique de constructeur. Pour un singleton conçu pour garantir une existence unique via un constructeur privé et une fabrique statique, cette intrusion crée un second objet distinct dans le tas, rompant ainsi la garantie d'égalité d'identité. En conséquence, l'état statique destiné à être global devient fragmenté à travers plusieurs instances, entraînant un comportement incohérent dans les applications reposant sur des points de contrôle uniques.

La solution : La méthode readResolve sert de crochet post-désérialisation défini dans le contrat Serializable, permettant à la classe de remplacer l'objet désérialisé par l'instance canonique avant qu'il ne soit retourné à l'appelant. En déclarant une méthode avec la signature exacte protected Object readResolve() throws ObjectStreamException, les développeurs peuvent intercepter le nouveau duplicata créé et retourner le champ statique INSTANCE à la place. Cette substitution se produit sans heurt dans le processus de résolution de flux, éliminant ainsi l'objet fallacieux vers la collecte des ordures tout en préservant l'intégrité du singleton.

public class Configuration implements Serializable { private static final Configuration INSTANCE = new Configuration(); private String dbUrl; private Configuration() { this.dbUrl = System.getenv("DB_URL"); } public static Configuration getInstance() { return INSTANCE; } protected Object readResolve() { return INSTANCE; } }

Situation de la vie réelle

Considérez une architecture de microservices distribués où un singleton DatabaseConfig gère les paramètres de connexion et les identifiants. Le service sérialise cette configuration dans un cache distribué comme Redis pour accélérer les démarrages à froid après déploiements. Lors d'événements de mise à l'échelle horizontale, de nouvelles instances de service récupèrent et désérialisent ce blob binaire, déclenchant involontairement le protocole de désérialisation par défaut.

Sans mesures défensives, ObjectInputStream instancie un objet DatabaseConfig distinct de l'INSTANCE statique détenue dans la JVM. Cette duplication crée un scénario de cerveau partagé où la nouvelle instance manque des crochets d'initialisation effectués lors de la construction statique, pointant potentiellement vers de vieux points de terminaison de base de données ou des fournisseurs d'identifiants non initialisés. L'application souffre ensuite de fuites de ressources alors que des pools de connexions en double apparaissent, épuisant les limites de connexion à la base de données et provoquant des défaillances en cascade à travers le cluster.

Une approche consiste à convertir le singleton en type Enum, tirant parti de la garantie de la JVM selon laquelle les énumérations sont des singletons par spécification et résistantes à la sérialisation par conception. Avantages : Le mécanisme de sérialisation gère automatiquement les constantes d'énumération par recherche par nom, empêchant totalement la création d'instance. Inconvénients : Les énumérations ne peuvent pas étendre des classes abstraites, limitant la flexibilité architecturale, et elles manquent de sémantique d'initialisation paresseuse, pouvant charger prématurément une configuration lourde lors de l'initialisation de la classe.

Alternativement, l'implémentation de la méthode readResolve dans la classe existante permet de retourner l'INSTANCE canonique après la fin de la désérialisation. Avantages : Cela préserve les hiérarchies d'héritage et prend en charge une logique d'initialisation complexe tout en gardant explicitement la création des doublons à distance. Inconvénients : Les développeurs oublient souvent cette méthode, et cela nécessite une synchronisation précise si l'instanciation du singleton lui-même est paresseusement initialisée et que la sécurité des threads n'est pas encore garantie lors de l'initialisation statique.

Une troisième option consiste à passer à Externalizable, contrôlant manuellement le flux de sérialisation via writeExternal et readExternal pour écrire uniquement des identifiants de configuration plutôt que l'état complet. Avantages : Cela empêche les attaques par création d'instance en refusant de sérialiser les internes de l'objet, récupérant plutôt la configuration à partir d'un magasin sécurisé lors de readExternal. Inconvénients : Cela introduit beaucoup de code standard et nécessite de maintenir la compatibilité descendante pour les formats de flux entre versions de l'application, augmentant la charge de maintenance.

L'équipe d'ingénierie a opté pour la solution 2, implémentant readResolve pour retourner le INSTANCE statique, car DatabaseConfig devait étendre une classe abstraite BaseConfiguration pour une fonctionnalité de journalisation d'audit partagée, rendant les énumérations inappropriées. Ils ont associé cela avec une initialisation hâtive pour éviter les préoccupations de synchronisation lors de la désérialisation, garantissant que le singleton existait avant que toute désérialisation puisse avoir lieu. Cette approche a équilibré une intrusion minimale dans le code avec une protection robuste contre la vulnérabilité de création de doublons.

Après la mise en œuvre, des tests de charge ont confirmé que la désérialisation des configurations mises en cache retournait des références d'objet identiques, éliminant les pools de connexions en double. Le service s'est étendu horizontalement sans épuisement des connexions à la base de données, et le profilage de la mémoire a vérifié qu'aucune instance supplémentaire de DatabaseConfig ne persiste dans le tas après les cycles de collecte des ordures. Cette résolution a maintenu une extensibilité architecturale tout en renforçant le contrat singleton contre les attaques de sérialisation.

Ce que les candidats manquent souvent

Comment l'interaction entre readObject et readResolve affecte-t-elle l'état des champs transitoires dans les singletons désérialisés ?

readObject reconstruit l'état complet de l'objet à partir du flux, y compris l'exécution de la logique d'initialisation personnalisée pour les champs transitoires, avant que la JVM ne considère l'objet comme complet. readResolve s'exécute ensuite, et s'il retourne une instance canonique différente, la JVM se débarrasse de l'objet temporaire entièrement reconstruit, y compris les valeurs transitoires calculées au cours de readObject. Les développeurs doivent manuellement copier l'état transitoire dans l'instance canonique à l'intérieur de readResolve si de telles données éphémères sont nécessaires, bien que pour de véritables singletons, les champs transitoires doivent généralement être re-dérivés à partir de l'état canonique plutôt que des flux sérialisés.

Pourquoi l'implémentation de Externalizable contourne-t-elle les protections offertes par readResolve ?

L'interface Externalizable déplace totalement le contrôle de la sérialisation à la classe via writeExternal et readExternal, contournant le mécanisme standard ObjectInputStream defaultReadObject qui vérifie readResolve. Lorsque readExternal remplit une instance nouvellement construite, le flux considère cela comme l'objet final et le retourne directement sans invoquer readResolve, à moins que le développeur ne l'appelle explicitement au sein de readExternal. Cette différence architecturale signifie que les développeurs utilisant Externalizable doivent implémenter manuellement la logique de contrôle des instances dans readExternal, généralement en lançant InvalidObjectException ou en fusionnant l'état dans le singleton de manière explicite, plutôt que de s'appuyer sur le crochet de substitution automatique.

Qu'est-ce qui empêche readResolve de fonctionner correctement au sein des types Record de Java ?

Les enregistrements se sérialisent et se désérialisent via leur constructeur canonique et leurs méthodes d'accès aux composants plutôt que par la population de champs basée sur la réflexion utilisée pour les classes traditionnelles, ce qui signifie que le processus de désérialisation ne crée jamais un objet shell vide que readResolve pourrait remplacer. La JVM reconstruit les enregistrements en invoquant le constructeur canonique avec les valeurs de composants désérialisées, rendant readResolve inapplicable puisque l'instance est totalement construite et immuable immédiatement après sa création. Pour atteindre un comportement semblable à celui des singletons avec des enregistrements, les développeurs doivent plutôt utiliser des méthodes de fabrique statiques marquées avec @Serial pour des proxys de sérialisation personnalisés, ou abandonner les enregistrements au profit de classes standard lorsque le contrôle strict des instances via readResolve est nécessaire.