← Retour au blog

Reconstruire du PHP à partir d'opcodes : guide pratique

Du PHP compilé, ça existe

Quand on pense « PHP », on imagine un script .php interprété à la volée. En réalité, PHP passe par un pipeline de compilation complet avant d’exécuter quoi que ce soit :

source.php → Lexer → Parser → AST → Compilateur → Opcodes → Zend VM

Le fichier source est d’abord tokenisé, puis parsé en arbre syntaxique abstrait (AST), puis compilé en opcodes, des instructions bas niveau pour la machine virtuelle Zend. C’est exactement le même principe que le bytecode Java ou le MSIL de .NET.

OPcache, l’extension standard de PHP, stocke ces opcodes en mémoire partagée pour éviter de recompiler à chaque requête. Certains outils vont plus loin : ils sérialisent la représentation compilée en fichiers binaires, permettant de distribuer du PHP sans livrer le code source.

Ces binaires compilés sont plus courants qu’on ne le pense. Certains éditeurs de logiciels commerciaux livrent exclusivement sous cette forme.

Ce que contient un op_array compilé

Chaque fonction, méthode ou bloc de premier niveau dans un fichier PHP compilé est représenté par un zend_op_array, une structure qui porte tout ce dont la VM Zend a besoin pour exécuter ce code. Le schéma ci-dessous montre les principaux champs et ce que chacun permet de reconstruire :

Anatomie d'un op_array : ce que chaque composant permet de récupérer

Du source à l’op_array, et retour

PHP n’a pas de commande officielle « compiler en binaire ». Mais à chaque exécution d’un script, PHP le compile en interne en op_arrays, et OPcache peut les persister sur disque. La directive opcache.file_cache demande à OPcache d’écrire des fichiers .bin compilés :

php -d opcache.enable_cli=1 \
    -d opcache.file_cache=/tmp/compiled \
    -d opcache.file_cache_only=1 \
    -r 'opcache_compile_file("script.php");'

Cela produit un fichier binaire contenant les op_arrays sérialisés. C’est exactement la même représentation qu’OPcache garde en mémoire partagée en production, et que la VM Zend exécute réellement. Tout outil qui capture les op_arrays à ce stade, que ce soit pour du cache, de la compilation anticipée ou de la distribution binaire, travaille sur le même format interne.

Pour inspecter le contenu d’un op_array, deux outils existent. phpdbg, le debugger intégré de PHP, peut dumper les opcodes compilés de n’importe quel fichier avec -p* :

phpdbg -n -p* script.php

Le flag -n désactive php.ini (et donc OPcache), ce qui donne la sortie brute du compilateur sans optimisation. Pour ce script :

<?php
$config = "production";
$data = json_decode(file_get_contents("config.json"), true);
if ($data["database"]) {
    connect($data["host"]);
}

phpdbg produit :

$_main:
     ; (lines=17, args=0, vars=2, tmps=7)
     ; script.php:1-7
0000 ASSIGN               CV0($config)    string("production")
0001 INIT_FCALL        2  112             string("json_decode")
0002 INIT_FCALL        1  96              string("file_get_contents")
0003 SEND_VAL             string("config.json")  1
0004 V3 = DO_ICALL
0005 SEND_VAR             V3              1
0006 SEND_VAL             bool(true)      2
0007 V4 = DO_ICALL
0008 ASSIGN               CV1($data)      V4
0009 T6 = FETCH_DIM_R     CV1($data)      string("database")
0010 JMPZ                 T6              0016
0011 INIT_FCALL_BY_NAME 1 string("connect")
0012 CHECK_FUNC_ARG       1
0013 V7 = FETCH_DIM_FUNC_ARG CV1($data)   string("host")
0014 SEND_FUNC_ARG        V7              1
0015 DO_FCALL_BY_NAME
0016 RETURN               int(1)

L’en-tête indique que cet op_array a 2 variables compilées et 7 temporaires. Chaque ligne est un opcode avec ses opérandes : CV0($config) = première variable locale, V3 = un temporaire de type VAR, string("...") = un littéral de la table des constantes.

Le second outil est le dump de debug d’OPcache. En configurant opcache.opt_debug_level, on peut dumper les opcodes à différents stades du pipeline d’optimisation. La valeur 0x10000 correspond à ZEND_DUMP_BEFORE_OPTIMIZER, la sortie brute du compilateur, avant toute optimisation :

php -d opcache.enable_cli=1 \
    -d opcache.opt_debug_level=0x10000 \
    -r 'opcache_compile_file("script.php");'

Cela produit une sortie identique à phpdbg -n -p*. Les deux montrent les mêmes op_arrays pré-optimisation. Comparez avec 0x20000 (ZEND_DUMP_AFTER_OPTIMIZER) pour voir ce que l’optimiseur change : les slots de temporaires sont compactés, certains opcodes sont spécialisés. Pour la décompilation, la forme pré-optimisation est préférable : elle correspond à la sortie originale du compilateur, avant qu’OPcache n’ait pu la transformer.

Cette sortie, ou un équivalent structuré parsé depuis un binaire compilé, est le point de départ du décompileur.

Transformer les opcodes en PHP : trois itérations

Itération 1 : Le parcours linéaire

La première version du décompileur traite les opcodes séquentiellement, un par un. L’idée est directe : chaque opcode correspond à un handler Python qui soit produit une expression (stockée dans un dictionnaire de temporaires), soit émet une instruction PHP complète.

temps = {}

for op in opcodes:
    if op.code == 'ASSIGN':
        temps[op.result] = f"{op.op1} = {resolve(op.op2)}"
        emit(temps[op.result] + ";")
    elif op.code == 'INIT_FCALL':
        push_call(op.op2)  # empiler le nom de fonction
    elif op.code == 'SEND_VAL':
        current_call().add_arg(resolve(op.op1))
    elif op.code == 'DO_ICALL':
        call = pop_call()
        expr = f"{call.name}({', '.join(call.args)})"
        if op.result:
            temps[op.result] = expr
        else:
            emit(expr + ";")

Les appels de fonction illustrent bien la difficulté : PHP compile json_decode(file_get_contents("x"), true) en 7 opcodes distincts. Il faut reconstruire la pile d’appels imbriqués : INIT_FCALL ouvre un contexte, chaque SEND_* ajoute un argument, DO_ICALL le ferme. Et ces appels s’imbriquent : l’argument d’une fonction peut être le résultat d’une autre.

Cette première version gère les assignations, les opérations arithmétiques, l’accès aux tableaux/propriétés, new, return et les appels de fonction. Elle produit du PHP valide pour du code linéaire, mais dès qu’il y a un if ou une boucle, on obtient des opcodes JMPZ et JMP bruts dans la sortie.

Itération 2 : Reconstruction du flot de contrôle

C’est de loin la partie la plus difficile. Il faut analyser les sauts (JMP, JMPZ, JMPNZ) avant la décompilation pour identifier les structures de contrôle.

L’analyse se fait en plusieurs passes :

Les boucles foreach sont les plus faciles à détecter. PHP les compile toujours de la même façon :

FE_RESET_R  $array       → end_opline
  ... corps ...
FE_FETCH_R  start_opline → $value
JMP         → body_opline
FE_FREE     $array

Le pattern FE_RESET + FE_FETCH + JMP arrière est unique, aucune ambiguïté.

Les boucles while se reconnaissent par un JMPNZ qui saute en arrière (vers le début du corps). Le JMP avant le corps saute vers la condition :

JMP         → condition
  ... corps ...
condition: JMPNZ expr → body

if/else : un JMPZ dont la cible est en avant crée un bloc if. Si l’opcode juste avant cette cible est un JMP inconditionnel, c’est un if/else. Le JMP saute par-dessus le bloc else.

switch/case est le plus complexe. PHP le compile comme une chaîne de comparaisons :

CASE        $var    "value1"    ~tmp
JMPNZ       ~tmp    → case1_body
CASE        $var    "value2"    ~tmp
JMPNZ       ~tmp    → case2_body
JMP         → default_or_end

Il faut regrouper ces chaînes par variable de switch, trouver le point de convergence (là où tous les break atterrissent) et classifier chaque bloc. En PHP 8+, les expressions match() produisent un pattern similaire mais avec IS_IDENTICAL au lieu de CASE.

Le résultat de cette phase est une carte d’annotations : pour chaque indice d’opcode, on sait s’il faut émettre if (, } else {, while (, }, etc. Le parcours linéaire de l’itération 1 consulte cette carte à chaque opcode.

Itération 3 : Les détails qui comptent

Avec les structures de contrôle en place, le gros du travail est fait. Mais les détails sont nombreux :

  • Les closures : l’opcode DECLARE_LAMBDA_FUNCTION référence un op_array imbriqué. Le décompileur le traite récursivement, reconstruit la signature (function($x) use ($y)) à partir des opcodes BIND_STATIC, et insère le corps indenté
  • L’opérateur @ : PHP le compile comme BEGIN_SILENCE / END_SILENCE autour de l’expression. On maintient un compteur de profondeur et on préfixe l’expression avec @
  • Le ternaire : $x ? $a : $b se compile en JMPZ + deux QM_ASSIGN séparés par un JMP. On détecte ce pattern et on synthétise l’expression sur une seule ligne
  • ?? (null coalesce) : l’opcode dédié COALESCE suivi de QM_ASSIGN produit $x ?? $default
  • Les valeurs par défaut : les opcodes RECV_INIT portent la valeur par défaut encodée comme nœuds AST dans les données de l’op_array. Il faut décoder les nœuds AST (constantes de classe, expressions binaires, constantes magiques…) pour reconstruire function foo($x = SomeClass::DEFAULT + 1)
  • L’aliasing des temporaires : certains compilateurs réutilisent les slots temporaires de façon non triviale. Un DO_FCALL écrit son résultat dans #5, mais le ASSIGN suivant lit depuis #3, qui n’a jamais été écrit. Il faut une carte d’alias positionnelle pour résoudre ces cas

Validation

Tout cela assemblé, on obtient un prototype fonctionnel du décompileur. Reste la partie la plus difficile : valider, déboguer, itérer.

Valider un décompileur est plus difficile qu’il n’y paraît. On ne peut pas comparer les fichiers sources : la sortie décompilée et l’original peuvent différer cosmétiquement tout en étant fonctionnellement identiques. La question qui compte n’est pas « le code a-t-il la même tête ? » mais « compile-t-il vers les mêmes opcodes ? ».

Notre méthode de validation est la comparaison d’opcodes aller-retour : on prend le PHP décompilé, on le recompile avec ZEND_DUMP_BEFORE_OPTIMIZER comme décrit plus haut, et on compare la séquence d’opcodes résultante à l’original, instruction par instruction. Les deux côtés sont au même stade de compilation (sortie brute du compilateur, avant toute optimisation), ce qui rend la comparaison significative.

Résultats

La sortie décompilée produit des séquences d’opcodes sémantiquement identiques sur tous les fichiers testés. Le code décompilé peut être syntaxiquement différent de l’original (espaces différents, blocs réordonnés, un while exprimé en for), mais ce qui compte est que la VM Zend exécuterait exactement la même séquence d’instructions. C’est ce que prouve l’aller-retour.

L’outil est entièrement statique : aucun code n’est exécuté, pas besoin de sandbox. Il traite un corpus de ~800 fichiers compilés en moins d’une seconde.

La reconstruction du flot de contrôle représente environ la moitié de l’implémentation, ce qui n’est pas surprenant puisque c’est là que se concentrent les cas limites. Un point notable : le décompileur passe directement des opcodes au code PHP, sans construire d’AST intermédiaire. Contrairement aux chaînes classiques de décompilation (JVM, .NET) qui passent par opcodes → CFG → SSA → AST → source, la richesse des opcodes PHP permet de s’en passer. Il n’y a pas d’obfuscation à défaire, juste des op_arrays standard à reconstruire.

Cet article décrit l’approche de haut niveau. Le code source est disponible sur demande. Si vous êtes chercheur en sécurité auditant des distributions PHP compilées, ou développeur ayant besoin d’interopérer avec un logiciel livré exclusivement sous forme de binaires compilés, contactez-nous.

Ce qu’on en retient

Les opcodes PHP sont un format riche. Comparés au bytecode de la JVM ou au CIL de .NET, où les noms de variables locales résident dans des symboles de débogage optionnels, la VM Zend les embarque directement dans le bytecode avec chaque littéral et chaque signature de fonction. Comparés au WebAssembly, qui élimine presque toute métadonnée source par conception, les op_arrays PHP ressemblent presque à du code source annoté. C’est cette richesse qui rend possible une reconstruction directe des opcodes vers du PHP, sans passer par un AST intermédiaire comme le font les décompileurs traditionnels pour Java ou .NET.

Du PHP « compilé » n’est pas du PHP protégé, c’est du PHP transformé. Et la transformation est réversible.