Research · Reverse Engineering

SourceGuardian PHP 5.5–8.5 — Static Container & Bytecode Recovery Without Execution

Loaders: ixed.5.5.winixed.8.5.win  ·  Target: SourceGuardian PRO 17.0 evaluation, PHP 5.5–8.5 (Windows)  ·  Tools: IDA Pro, Python

sourceguardian php5.5 php7.4 php8.0 php8.1 php8.2 php8.3 php8.4 php8.5 static-analysis blowfish lzo1x tlv zend-opcodes ida-pro
6pipeline stages, container to opcode IR
258key-candidate search space (3 fixed + 255 derived)
8real encode/decode round trips, PHP 5.5 through 8.5
0PHP runtime executions required

§ Overview

SourceGuardian is a commercial PHP obfuscation system, positioned similarly to ionCube: it strips a source file down to a proprietary encrypted container and relies on a closed-source Zend extension (the loader) to reconstitute a runnable zend_op_array at request time. The usual way to study a format like this is to hook the loader while PHP actually runs the file — which needs a matching PHP build, the loader installed, and leaves runtime traces.

This article documents a fully static path instead, built by reversing the Windows loader/encoder artifacts of a SourceGuardian PRO 17.0 evaluation install — starting from ixed.8.1.win and sgencoder81.dll. Every stage below — container framing, key resolution, Blowfish-CBC decryption, checksum validation, LZO1X inflation, and bytecode-container parsing — was identified in IDA Pro from the binaries alone and reproduced in Python. The same fixture logic was then encoded separately for eight PHP builds — 8.0 through 8.5, plus 5.5 and 7.4 using a second fixture with the same logic minus syntax those older versions can't compile — and decoded with the identical Python pipeline, no version-specific branches, to check how far the format travels. No PHP process is started anywhere in the pipeline, for any version.

⛔ encoded — what SourceGuardian ships
article_fixture.php16,088 chars
<?php ... sg_load('21311D57C7C4F42FAAQAAAAhAAAABLAAAACABAAAAAAAAAD/05nFSZ/7E6ardYHCSGWAYBcD4TybWIKwUPZOSdNzJIwkZXDjr9xoOnWWeKaSEq35t1lmKs/9Piz2R0DVxZCXE7fm645PncaKO4Dm70a3PXV7dRUr5eh+iOo09d+Q5iGg8XVLMPZQLl17SUr7bX9E6jlL3yezshQIe4MYLNu9g3xD5aE2fxV1BluNpzYpkQcBwrWgt0kK0JkbuqNo/lPlWxpO8yshgmji6k0WtfTpspVSAAAAmAsAAOxDp8ZaKNRdIBFEkhe2F/d1P95rdHk4UI0tPXb1qjTxfRhyfBr9TudO4W3Isk8Eww7q5J8ytnDvKdvJfGWEJL94qDV/OGDMgYj2GnQe2lGQQluGAcCa9QFrSz9jWl2/zivYXF44qPCwTPzxFy/kyRh5HEFfIJ7r37WEGhCVca976Q65/E+1W39u1Mdv…' /* 15,608 more characters */) ?>
✅ source — what actually compiles to that
article_fixture.phpbefore encoding
class ShoppingCart
{
    private array $items = [];
    private float $discountRate = 0.0;

    public function addItem(string $name, float $price, int $qty = 1): void
    {
        $this->items[] = [
            'name'  => $name,
            'price' => $price,
            'qty'   => $qty,
        ];
    }

    public function setDiscount(float $rate): void
    {
        $this->discountRate = match (true) {
            $rate < 0.0 => 0.0,
            $rate > 0.5 => 0.5,
            default     => $rate,
        };
    }
    // total() and summary() follow the same shape — see §11 for the full file

Both sides are real: the left is an unedited prefix of the actual argument sg_load() receives for this article's fixture; the right is the exact source that produced it, encoded with a licensed SourceGuardian PRO evaluation copy (§0 scope note). Nothing here is a mock-up — the rest of this article is about working out the container format shown on the left with only bytes like these to go on, not source code to check against.

💡
The headline structural finding: SourceGuardian's commercial deployment mode (the one used for ordinary "buy once, ship anywhere" licensing) is not per-file or per-customer keyed. It decrypts under one of only three fixed Blowfish keys embedded verbatim in every copy of the loader — see §06.
article_fixture.php sg_load('...') — encoded input base64 decode, drop 12B sig buf preheader (plain) + 2 encrypted sections parse preheader TLV → ver, v58 Mode A — Commercial 3 fixed keys, all deployments Mode B — Eval-locked CONST16 + name-length byte key = eval-0x09 258 candidates tried · ROL1 gate (§04) Blowfish-CBC-IV0 decrypt (§03) decrypt metadata + body same resolved key, both sections LZO1X inflate (§05) meta.bin regkey + license fields (§07) body.bin SOURCEGUARDIAN container parse container (§08) SOURCEGUARDIAN magic ver=0x21 flags=0x7 (§08) function table tag=2 entries class table tag=1 entries main op_array always last resolve opcodes against PHP 8.1 zend_vm_opcodes.h emit readable opcode listing .icdump.txt + .icdump.json static — zero PHP execution
The full static decode pipeline for article_fixture.php: preheader framing, the commercial/eval key-mode branch merging at the ROL1 checksum gate, shared Blowfish-CBC-IV0 decryption of the metadata and body sections, LZO1X inflation, and the SOURCEGUARDIAN container's three record tables resolving into one readable opcode listing.

What's confirmed vs. what's a documented method

LayerStatus on PHP 8.0–8.5 / Windows
Container framing (sg_load, preheader TLV)confirmed, byte-identical on 8.1/8.3/8.4/8.5
Blowfish-CBC-IV0 + ROL1 checksum gateconfirmed on all six versions
LZO1X decompressionconfirmed on all six versions
Key resolution (commercial + eval-derived)confirmed, method documented
SOURCEGUARDIAN container → zend_op_arrayconfirmed — the parser itself handles functions, classes, and main op_arrays (verified during development against a range of real SG-encoded files); article_fixture.php specifically hits a known edge case partway through its first function, before reaching its class or main sections (§08). 8.0 additionally has an extra unparsed container block (§10)

This article's worked example was independently encoded and decoded on all six PHP 8.x versions above, plus a second, older-style fixture on PHP 5.5 and 7.4 — eight real encode/decode round trips in total, not one build checked and assumed to generalize. §10 has the full per-version breakdown, including a real container shape difference on 5.5 that neither 7.4 nor 8.0 share, and what's still only checked at the constants level (PHP 5.6–7.3, and every non-Windows platform).

Scope and intent

This is a static-format research write-up, not a license- or activation-bypass guide. It documents how SourceGuardian's container, encryption, and bytecode-serialization formats work, recovered by reversing the loader and encoder binaries in IDA Pro. It explicitly does not:

  • host or redistribute SourceGuardian's proprietary ixed.*.win loader or sgencoder*.dll encoder — those are SourceGuardian Ltd.'s copyrighted software; get your own evaluation copy directly from SourceGuardian and use it locally with the toolchain below;
  • discuss, weaken, or provide any tooling for SourceGuardian's separate license-manager / signature-verification mechanism (encrypted license files, RSA-signed activation records, domain/machine locking checks). That subsystem is not reversed or touched anywhere in this article;
  • decode or publish any real third party's protected source. The worked examples throughout — article_fixture.php (a small shopping-cart class exercising typed properties, match, foreach, and string interpolation) and, for the pre-8.0 builds in §10, a second article_fixture_legacy.php with the same logic minus the version-gated syntax — are original files written specifically for this article and encoded with a licensed SourceGuardian PRO evaluation copy; neither is anyone's commercial code;
  • execute or require a PHP runtime for any encoded input. Every step described here is static — file bytes in, structured data out — the same category of work as documenting any other proprietary binary container format.
⚠️
One documented property — the fixed, globally-shared commercial keys in §06 — is a genuine confidentiality weakness in SourceGuardian's design: any holder of the loader binary can derive the same three keys. This is disclosed here in the same spirit as published research on other commercial obfuscators' weaknesses: the extraction method is shown in full; the current live key values are intentionally not printed verbatim, since the method alone is sufficient to reproduce them against a licensed copy and printing them would only turn the article into a copy-paste master-key sheet.

01 The sg_load() Container

An encoded file is a normal PHP source file whose payload lives entirely inside one call to a loader-registered function. There is no custom marker byte sequence to hunt for the way ionCube uses HR+c; the entire encoded blob is the single string argument to sg_load():

article_fixture.php — encoded shape
<?php ... sg_load(' [16 hex chars] [base64 payload] ') ?>
              │                │
              │                └─ base64 decode → binary blob
              └─ j (8 hex) = last 4 bytes of regkey (machine id)
                 v35 (8 hex) = content checksum

The Python side just needs the last sg_load('...') call in the file (the loader wraps it in some boilerplate PHP before it) and everything between the opening quote and the closing '):

get_payload
def get_payload(path):
    text = open(path, 'r', encoding='latin-1').read()
    i = text.rfind("sg_load('")
    s = i + 9
    e = text.index("')", s)
    return base64.b64decode(text[s:e])

Base64-decoding that argument yields a binary blob whose first 12 bytes are a machine-signature prefix (the base64-encoded j/v35 fields shown above — dropped once parsed) — everything after that is buf, the structure the rest of this article walks through:

buf layout
[preheader TLV]     plaintext, ~24 bytes                    → §02
[metadata section]  Blowfish-CBC-IV0 encrypted               → §03–§05, §07
[body TLV + data]   Blowfish-CBC-IV0 encrypted, remainder    → §03–§05, §08
🔍
In IDA: the loader's sg_load handler is sub_100017D0; it calls straight into the main decode worker at sub_100023F0, which is the entry point for the whole pipeline described in this article (base64 → Blowfish → LZO → deserialize).

02 Preheader TLV — the Only Plaintext Part

Before any decryption, buf opens with a short tag-length-value stream, read by a dedicated reader (sub_1000ADD0 for init, sub_1000C3B0/sub_1000C430 for tag/varint reads). It is deliberately tiny — just enough to tell the loader how big the encrypted metadata section is before it can decrypt anything:

TagPayloadMeaning
0x00[skip 4][ver u32]format version — 0x21 (33) on this build
0x04[v58 u32]size in bytes of the encrypted metadata section that follows
0x80[skip 4][flags u32]machine-bind flags (selects commercial vs. eval key derivation)
0xFFend of preheader (~24 bytes total)
parse_preheader
def parse_preheader(buf):
    pos, ver, v58 = 0, None, None
    while True:
        tag = buf[pos]; pos += 1
        if tag == 0xFF:
            break
        if tag == 0x00:
            struct.unpack_from('<I', buf, pos); pos += 4       # skip field
            ver = struct.unpack_from('<I', buf, pos)[0]; pos += 4
        elif tag == 0x04:
            v58 = struct.unpack_from('<I', buf, pos)[0]; pos += 4
        else:
            skip = struct.unpack_from('<I', buf, pos)[0]; pos += 4
            pos += skip
    return ver, v58, pos   # pos = offset where the encrypted metadata section starts

v58 is the load-bearing field: metadata section starts right after the preheader and is exactly v58 bytes; the body TLV starts immediately after that, at pre_end + v58.

03 Blowfish-CBC-IV0

Both the metadata and body sections are encrypted the same way: Blowfish in CBC mode with a zero IV. There is nothing proprietary about the cipher itself — ixed.8.1.win's key-schedule (sub_10007D90) and CBC decrypt (sub_100083C0) initialize P and S from Bruce Schneier's standard published pi-constants; confirmed at 0x10014768 (P-array) and 0x100147B0 (S-boxes) in the binary. The confidentiality here rests entirely on which key gets fed into that standard schedule — covered in §06 — not on any modification to Blowfish itself.

IDA Pro Hex-Rays decompiler view of the top of sub_10007D90 in ixed.8.1.win: a nested loop copying dword_100147B0 into the S-boxes, followed by a loop that builds a 32-bit word from the key bytes and XORs it against dword_10014768 to produce each P-array entry.
sub_10007D90 · 0x10007D90 — the two lines that matter: dword_100147B0[256*i+j] (line 31) seeds the S-boxes, v6 ^ dword_10014768[k] (line 46) XORs a key-derived word against the P-array constants. These are the two addresses cited above, and the second is exactly P[k] = word ^ PINIT[k] in schedule() below.
schedule / cbc_decrypt sub_10007D90 · sub_100083C0
def schedule(key):
    P, S = list(PINIT), list(SINIT)          # standard pi-constants
    klen, ki = len(key), 0
    for k in range(18):
        word = 0
        for _ in range(4):
            word = (key[ki] | (word << 8)) & M
            ki = (ki + 1) % klen
        P[k] = word ^ PINIT[k]

    def F(x):
        return ((((S[(x>>24)&0xff] + S[256+((x>>16)&0xff)]) & M)
                 ^ S[512+((x>>8)&0xff)]) + S[768+(x&0xff)]) & M

    def mix16(l, r):                          # 16-round Feistel core, run on (l, r)
        for i in range(16):
            tmp = P[i] ^ l
            l, r = r ^ F(tmp), tmp
        return l, r

    l = r = 0
    for n in range(0, 18, 2):                 # fill P, two words per pass
        l, r = mix16(l, r)
        r, l = P[16] ^ l, P[17] ^ r
        P[n], P[n+1] = l, r

    for plane in range(4):                    # fill the 4 S-box planes, 256 words each
        for k in range(0, 256, 2):
            l, r = mix16(l, r)
            r, l = P[16] ^ l, P[17] ^ r
            S[plane*256+k], S[plane*256+k+1] = l, r

    return P, S, F

def cbc_decrypt(P, S, F, ct, iv=(0, 0)):
    out = bytearray()
    v5, v4 = iv                              # IV = (0, 0) — always
    words = struct.unpack('<%dI' % (len(ct)//4), ct)
    i = 0
    while i + 1 < len(words):
        L, R = bswap(words[i]), bswap(words[i+1])
        for j in range(17, 1, -1):           # Feistel network, 16 rounds, reversed
            L, R = R ^ F(P[j] ^ L), P[j] ^ L
        out += struct.pack('<II',
                            (v5 ^ bswap(P[0] ^ R)) & M,
                            (v4 ^ bswap(P[1] ^ L)) & M)
        v5, v4 = words[i], words[i+1]        # ciphertext feeds next block (CBC)
        i += 2
    return bytes(out)

This is the standard Blowfish key-schedule trick: encrypt an all-zero block with the cipher's own current state, over and over, and use the output words to progressively fill P and then all four S-box planes. Nothing about it is SourceGuardian-specific — it's the textbook algorithm from Schneier's original specification.

04 Section Framing & the ROL1 Checksum Gate

Once a section is Blowfish-decrypted, its first 12 bytes are a small header that both describes the section and — critically — lets the decoder confirm it used the correct key without needing to understand anything else about the content yet:

decrypted section layout
[cksum   4 LE]   ROL1 checksum of everything from byte 4 to `len`
[len     4 LE]   total section length, including this 12-byte header
[declen  4 LE]   decompressed size (0 = section is stored raw, no LZO)
[data]           LZO1X-compressed payload, (len - 15) bytes
[pad     3 bytes]

The checksum is a simple rotate-and-add accumulator (sub_100092B0 in the loader), run over the section with its own stored checksum field zeroed out:

rol1
def rol1(data: bytes) -> int:
    v = 0
    for b in data:
        v = (v + b) & 0xFFFFFFFF
        v = ((v << 1) | (v >> 31)) & 0xFFFFFFFF
    return v

# applied to: b"\x00\x00\x00\x00" + section_bytes[4:len]
IDA Pro graph view of sub_100092B0 in ixed.8.1.win: an init block zeroing an accumulator and index, a loop-condition block comparing the index against the length argument, a loop body that adds the next byte into the accumulator then rotates it left by one bit (shl 1, extract and OR back the bit that fell out of the top), an increment block, and an exit block returning the accumulator.
sub_100092B0 · 0x100092B0 — the ROL1 checksum, in full. Every instruction in the loop body maps onto rol1() one for one: add eax, [ebp+var_4] is v = v + b; the following shl ecx, 1 / and edx, 80000000h / shr edx, 1Fh / or ecx, edx block is the rotate-left-by-1, (v << 1) | (v >> 31); the right-hand exit block's mov eax, [ebp+var_4] is the return v.

This gate is what makes offline key-search practical (§06): decrypt a candidate section with each candidate key, recompute rol1, and keep the key only if the recomputed value matches the stored one and len/declen land inside sane bounds. Wrong key → the first 12 bytes decrypt to noise → the checksum essentially never matches by accident:

_try_decrypt — key-validity gate
def _try_decrypt(key, buf, off):
    P, S, F = schedule(key)
    seg = buf[off:]; seg = seg[:len(seg) & ~7]      # round down to 8-byte blocks
    pt = cbc_decrypt(P, S, F, seg)

    stored = int.from_bytes(pt[0:4],  'little')
    ln     = int.from_bytes(pt[4:8],  'little')
    declen = int.from_bytes(pt[8:12], 'little')
    if stored == 0 or not (0 < ln <= min(len(pt), 200_000)):
        return None
    if not (0 <= declen < 5_000_000):
        return None

    if rol1(b'\x00\x00\x00\x00' + pt[4:ln]) != stored:
        return None
    return pt, ln, declen

05 LZO1X Decompression

With declen > 0, the bytes from offset 12 to len - 3 are a standard LZO1X stream — the same family minilzo implements, ported byte for byte from the loader's decompressor at sub_100088E0. It's a small state machine driven by one control byte at a time (literal run, then a sequence of copy/match instructions), ending on a zero-distance match that acts as the EOF marker:

lzo1x_decompress sub_100088E0
def lzo1x_decompress(src: bytes) -> bytes:
    ip, out = 0, bytearray()

    def literal(n):
        nonlocal ip
        out.extend(src[ip:ip+n]); ip += n

    def copy_match(m, count):
        for _ in range(count):
            out.append(out[m]); m += 1

    state = 'top'
    t = src[ip]
    if t > 17:                       # long first literal run
        ip += 1; t -= 17
        literal(t); state = 'flr' if t >= 4 else 'match'

    while True:
        if state == 'top':
            t = src[ip]; ip += 1
            if t >= 16:
                state = 'match'; continue
            if t == 0:                # extended literal-run length
                while src[ip] == 0: t += 255; ip += 1
                t += 15 + src[ip]; ip += 1
            literal(t + 3); state = 'flr'

        elif state == 'flr':          # short 2-byte match after a literal run
            t = src[ip]; ip += 1
            if t >= 16:
                state = 'match'; continue
            d = 0x0801 + (t >> 2) + (src[ip] << 2); ip += 1
            m = len(out) - d
            out.append(out[m]); out.append(out[m+1]); out.append(out[m+2])
            state = 'mdone'

        elif state == 'match':        # M2 / M3 / M4 encodings by control-byte range
            if t >= 64:
                d = 1 + ((t >> 2) & 7) + (src[ip] << 3); ip += 1
                count = (t >> 5) - 1
            elif t >= 32:
                count = t & 31
                if count == 0:
                    while src[ip] == 0: count += 255; ip += 1
                    count += 31 + src[ip]; ip += 1
                d = 1 + (src[ip] >> 2) + (src[ip+1] << 6); ip += 2
            else:
                h = (t & 8) << 11; count = t & 7
                if count == 0:
                    while src[ip] == 0: count += 255; ip += 1
                    count += 7 + src[ip]; ip += 1
                d = h + (src[ip] >> 2) + (src[ip+1] << 6); ip += 2
                if d == 0:
                    break              # zero distance == end of stream
                d += 0x4000
            m = len(out) - d
            out.append(out[m]); out.append(out[m+1]); copy_match(m+2, count)
            state = 'mdone'

        elif state == 'mdone':
            trailing = src[ip-2] & 3
            if trailing == 0:
                state = 'top'
            else:
                literal(trailing); t = src[ip]; ip += 1; state = 'match'
    return bytes(out)
Validated by writing an independent LZO1X compressor from scratch and round-tripping decompress(compress(x)) == x across sizes from a few bytes up to real body-section sizes (100–400 KB), plus fully-structured repetitive input designed to exercise every one of the four match encodings above.

06 Key Derivation — Two Modes

Everything up to here (framing, cipher, checksum, decompressor) is fixed across every SourceGuardian-encoded file. What varies is the 44-byte ASCII Blowfish key fed into schedule() in §03 — and the loader supports exactly two ways to arrive at one.

Mode A — Commercial / deployment files

For ordinary "encode once, deploy anywhere the loader is installed" licensing, the key is one of three fixed strings living in a NULL-terminated pointer array inside the loader's read-only data — off_1001705C in ixed.8.1.win. They are used as-is, unmodified, as 44-byte ASCII Blowfish keys:

off_1001705C — key array (shape, values masked) ixed.8.1.win
KEY0 = "<44-char base64 ASCII string>"   # file offset 0x15668
KEY1 = "<44-char base64 ASCII string>"   # file offset 0x15698
KEY2 = "<44-char base64 ASCII string>"   # file offset 0x156cc
NULL                                       # array terminator
⚠️
These three strings are identical in every distributed copy of a given loader build — they are not derived from a license, a machine, or a customer in any way. Anyone holding the loader binary can extract them with a two-minute static string search and decrypt any file encoded in commercial mode with that build. That's the finding; the current literal values are intentionally not reproduced here (see the note in §0).

Finding them yourself, against your own evaluation copy

IDA / static-string search recipe
1. Open ixed.8.1.win in IDA Pro (or a hex editor)
2. Search the .rdata section for ASCII strings matching [A-Za-z0-9+/=]{40,48}
3. You will find exactly three, sitting next to each other, followed by a NULL
   pointer terminating the array — those are KEY0 / KEY1 / KEY2
4. Each is used verbatim (still base64-looking ASCII, never itself decoded)
   as the key argument to schedule() from §03

Mode B — Evaluation / machine-locked files

Files encoded in "restrict to this machine" evaluation mode use a key derived from the encoding machine's own registration key, not from a fixed table. The chain, reconstructed from sourceguardian.exe (sub_40CAB0, sub_40CB10) and the encoder's license decrypt routine (sub_10002CF0):

eval-key derivation chain
regkey = MD5( sprintf("%08X", scramble(VolumeSerial)) ).upper()   # 32 ASCII hex chars
         where scramble() = ROL7 + a fixed bit-permutation over the volume serial

encode.lic (per-machine license file) decrypts, with regkey as the Blowfish key, to:
  [inner_len=16 LE][16-byte slot][name_len LE]["registered name" + \0]

sourceguardian.exe then overwrites that 16-byte slot with a hardcoded constant
(CONST16 = KEY1[0:16] from Mode A) and derives the final per-file key as:

  key = CONST16 + bytes([ name_len & 0xFF ])   # 17 bytes total

Because the only unknown left in that formula is a single length byte, and registered names are realistically 1–255 characters, an offline decoder does not need encode.lic or the volume serial at all — it can simply try all 255 possible name_len byte values:

candidate list — 3 fixed + 255 derived = 258
CANDIDATES = [('commercial-key0', KEY0), ('commercial-key1', KEY1), ('commercial-key2', KEY2)]
for n in range(1, 256):
    CANDIDATES.append((f'eval-{n:#04x}', CONST16 + bytes([n])))

def find_key(buf, meta_off):
    for name, key in CANDIDATES:
        result = _try_decrypt(key, buf, meta_off)   # §04's checksum gate decides
        if result is not None:
            return name, key, result
    return None, None, None
ℹ️
258 Blowfish attempts per file is fast — the §04 checksum gate rejects a wrong key from just the first 12 decrypted bytes, so the whole search is a few milliseconds even on a large body section.

07 Decrypted Metadata Layout

Once the correct key is found, the metadata section (§03–§05) inflates to a small TLV record describing the license this file was encoded under — its shape is fixed regardless of which key matched:

metadata TLV — field shapes
\x02                       tag: machine-info block
[regkey_len   u32 LE]      = 33 (32 ASCII hex chars + trailing NUL)
[regkey       32 bytes]    ASCII hex, e.g. "<32 hex chars>"
\x00
[tag u8][len u32 LE][value]  ...repeated TLV fields: encode date,
                              license/registered-name string, expiry,
                              domain-lock list, ...
0xFF                        end
walking the TLV
if meta[0] == 0x02:
    rk_len = struct.unpack_from('<I', meta, 1)[0]
    regkey = meta[5:5+rk_len-1].decode('ascii', 'replace')
    pos = 5 + rk_len
    while pos < len(meta) - 5:
        tag = meta[pos]; pos += 1
        if tag == 0xFF:
            break
        flen = struct.unpack_from('<I', meta, pos)[0]; pos += 4
        value = meta[pos:pos+flen]; pos += flen
        # tag 0x00 among these fields is the human-readable license/registrant name

This article does not print the real field values decoded from any specific license — they identify the person or company the evaluation copy is registered to, which is exactly the kind of detail out of scope per §0. The TLV shape above is what matters for reproducing the parser.

08 The SOURCEGUARDIAN Bytecode Container

The body section inflates to a 15-byte magic followed by a serialized zend_op_array tree — main script, top-level functions, and classes (with their methods), each wrapped in a small tag/length/key record before the actual opcode data:

body.bin — top level
b"SOURCEGUARDIAN\x00"        15-byte magic
[ver     u32 LE]              container version, ≤ 0x21 on this build
[flags   u32 LE]              bit 1 set → hybrid-VM pre-data present per opcode
[f3, f4  u32 LE]               additional flags/reserved words

-- function table --
  ( [tag=2][key_len u32][sg_key bytes] [op_array] )*   0x00  -- table terminator

-- class table --
  ( [tag=1][key_len u32][sg_key bytes] [class record] )*   0x00

-- main op_array --   (always present, always last)

A small stream reader underlies every field access below it — little-endian fixed-width integers, length-prefixed strings, and explicit EOF checks so a truncated or misaligned container fails with a clear parse error instead of silently reading garbage:

StreamReader
class StreamReader:
    def __init__(self, data):
        self.data, self.pos = data, 0

    def read_u8(self):    v = self.data[self.pos];                self.pos += 1; return v
    def read_u32(self):   v = struct.unpack_from('<I', self.data, self.pos)[0]; self.pos += 4; return v
    def read_double(self):v = struct.unpack_from('<d', self.data, self.pos)[0]; self.pos += 8; return v
    def read_bytes(self, n): v = self.data[self.pos:self.pos+n];   self.pos += n; return v

    def read_zstr(self):
        n = self.read_u32()
        if n == 0xFFFFFFFF:
            return None
        return self.read_bytes(n).decode('utf-8', 'replace')
parse_container — top-level walk
def parse_container(data: bytes) -> dict:
    r = StreamReader(data)
    magic = r.read_bytes(15)
    if magic != b'SOURCEGUARDIAN\x00':
        raise ParseError(f'bad magic {magic!r}')

    ver, flags, _f3, _f4 = r.read_u32(), r.read_u32(), r.read_u32(), r.read_u32()
    if ver > 0x21:
        raise ParseError(f'unsupported container version {ver}')
    has_hybrid_vm = bool(flags & 2)

    functions, classes = [], []
    for phase in range(3):                      # 0/2: function table, 1: class table
        if phase == 1:
            while (tag := r.read_u8()) != 0:
                if tag != 1: raise ParseError(f'class tag={tag:#x}')
                r.read_bytes(r.read_u32())        # skip SG composite key
                classes.append(parse_class(r, ver, has_hybrid_vm))
            if ver >= 0x1D: break                # modern container: stop after classes
        else:
            while (tag := r.read_u8()) != 0:
                if tag != 2: raise ParseError(f'func tag={tag:#x}')
                r.read_bytes(r.read_u32())        # skip SG composite key
                functions.append(parse_op_array(r, ver, has_hybrid_vm))
            if ver < 0x1D: break

    main = parse_op_array(r, ver, has_hybrid_vm)  # always present, always last
    return {'ver': ver, 'flags': flags, 'functions': functions, 'classes': classes, 'main': main}

Opcode identity: real Zend numbers, wrapped rather than replaced

SourceGuardian does not invent its own opcode numbering. Zend's zend_set_user_opcode_handler API — a real, public part of the PHP extension ABI — lets an extension intercept specific opcode IDs and hand execution to its own handler while everything else about the zend_op_array stays a normal Zend structure. SourceGuardian uses exactly that: opcode numbers in the container match PHP 8.1's own Zend/zend_vm_opcodes.h table one-for-one, which is exactly what makes recovering readable mnemonics straightforward — the reference table is PHP's own public source, not anything extracted from the loader:

reference table (excerpt) — from Zend/zend_vm_opcodes.h, PHP 8.1
OPCODE_NAMES = {
    0:  'ZEND_NOP',        1:  'ZEND_ADD',          2:  'ZEND_SUB',
    22: 'ZEND_ASSIGN',     42: 'ZEND_JMP',          43: 'ZEND_JMPZ',
    60: 'ZEND_DO_FCALL',   62: 'ZEND_RETURN',       71: 'ZEND_INIT_ARRAY',
    136:'ZEND_ECHO',       144:'ZEND_DECLARE_CLASS',195:'ZEND_MATCH',
    # ... 203 entries total for PHP 8.1 (range 0–202)
}

Each function/method's op_array record carries its opcodes, typed operand slots (IS_CONST / IS_TMP_VAR / IS_VAR / IS_CV), the literal table, and the compiled variable-name table — all in plaintext once the container is parsed, since the string obfuscation happens at the compression/encryption layer (§03–§05), not inside the container format itself. That is why symbol names, string literals, and control-flow structure are recoverable directly from the parsed op_array, without needing to touch the opcode-numbering question at all.


09 The Encoder Side — sgencoder81.dll

The encoder DLL is the symmetric counterpart of everything above — worth a brief look because it confirms the pipeline is genuinely reversible rather than a one-way function that happens to be invertible by accident:

Export / addressRole
encode_buffer (ord. 180, 0x10001300)PHP source → encrypted sg_load blob
init_encoder (ord. 220, 0x10001b10)encoder init, derives the machine regkey (§06 Mode B)
sub_10003210 / sub_100035D0Blowfish key-schedule / CBC encrypt — inverse of §03
sub_10005090LZO1X compress — inverse of §05
sub_10004FB0ROL1 checksum, encoder side — same algorithm as §04
sub_1000B680zend_op_arraySOURCEGUARDIAN container serializer — inverse of §08; not yet fully mapped

Everything through the LZO1X/Blowfish/checksum layers has a confirmed inverse; the container serializer (sub_1000B680) is the one piece still open, tracked honestly in §10 rather than assumed.

T Toolchain

The pipeline above is implemented as a small Python 3.10+ toolkit, standard library only. It never invokes PHP or the loader — every input is parsed purely as bytes. The commercial/eval key values are intentionally not baked into the published decoder (see §06); sg_key_extractor.py shows how to pull them from your own licensed loader copy, and the decoder reads them from a small local file you create yourself.

FileRole
sg_key_extractor.pyScans a loader binary's read-only data for the 3-entry NULL-terminated key array (§06 recipe), prints candidates to fill into your own sg_keys.py
sg_container_decode.pysg_load parsing, preheader TLV, Blowfish-CBC-IV0, ROL1 gate, key search (§01–§07) — writes *.meta.bin / *.body.bin
sg_lzo1x.pyStandalone LZO1X decompressor (§05)
sg_bytecode_ir.pySOURCEGUARDIAN container parser (§08) → normalized JSON IR + a readable opcode listing

Available loader & encoder binaries

This site does not host or redistribute SourceGuardian's proprietary ixed.*.win/sgencoder*.dll binaries. Get an evaluation copy directly from sourceguardian.com and point the toolchain above at your own local copy.

Step 0 — run the fixture through the pipeline

CLI
# 1. extract this build's commercial keys once, from your own ixed.8.1.win
python sg_key_extractor.py ixed.8.1.win > sg_keys.py

# 2. decode the fixture from this article
python sg_container_decode.py article_fixture.php --out dump/

# 3. turn the body section into a readable opcode dump
python sg_bytecode_ir.py dump/article_fixture.body.bin

Run against the real encoded article_fixture.php, step 2 confirms every layer from §01–§07 end to end — key search, Blowfish-CBC decrypt, both checksum gates, and LZO1X inflate all succeed on genuine ciphertext, not just the synthetic examples used to illustrate the format above:

real output — python sg_container_decode.py article_fixture.php --out dump/
[article_fixture] key=eval-0x09 preheader(ver=33 v58=176) meta=161B body=6807B
FieldValue
matched key candidateeval-0x09 — the 10th of 258 tried, confirming this file uses Mode B (§06)
metadata section169 B ciphertext → 161 B plaintext, checksum OK
body section2 964 B ciphertext → 6 807 B plaintext, checksum OK
container headerver=0x21 flags=0x7, first function-table entry's SG composite key is the literal string builddemocart — a lowercased copy of the source function name, used as a lookup key rather than anything cryptographic
⚠️
Step 3, on this specific fixture, hits a genuine open edge case rather than completing cleanly: sg_bytecode_ir.py parses the container header and begins walking buildDemoCart()'s literal table correctly, then desyncs a few bytes after the first floating-point literal. The same failure, at the same byte offset, reproduces against the project's own more heavily-tested internal parser — this is not a difference introduced by the cleaned-up toolchain above, it's an un-solved piece of the double/float literal encoding that none of the previously-analyzed real-world files happened to exercise. Everything through §07 (the actual encryption/compression research) is unaffected; only the last stage of §08's literal-table walk hits it. Full op_array recovery for files with float literals is tracked as follow-up work.
what step 3 actually recovers, in order, before it stops
literal[0] = "shoppingcart"   # lowercased class-name reference (ShoppingCart)
literal[1] = "addItem"        # exact-case method name, from $cart->addItem(...)
literal[2] = "additem"        # lowercased twin of literal[1] — a case-insensitive
                               # method-lookup key PHP's own compiler emits alongside
                               # the exact-case name, not something SG adds
literal[3] = "Widget"         # first string argument of the first addItem() call
literal[4] = <float, undecoded>   # the 9.99 price argument — parsing desyncs here

Literals 0–3 are the actual recovered values, not a mock-up — real ShoppingCart/addItem string data, pulled straight out of the decrypted, decompressed container. The literal[1]/ literal[2] pair is worth noticing on its own: PHP's compiler stores a method call's exact-case name and a lowercased copy side by side, which is a general PHP compilation detail (case-insensitive method dispatch), not anything SourceGuardian invented.

This same fixture was also encoded and run through this exact pipeline on PHP 8.0, 8.2, 8.3, 8.4, and 8.5 — see §10 for the full per-version results, including one real structural difference on PHP 8.0.

10 Porting to Other PHP Versions & Platforms

Everything through §05 — container framing, Blowfish-CBC-IV0, the ROL1 gate, LZO1X — is a property of the SourceGuardian product, not of a specific PHP ABI. What's genuinely version-specific is (a) the three §06 Mode-A key strings, which are per-loader-build in principle, and (b) the zend_vm_opcodes.h reference table used to name opcodes in §08, which changes with the PHP ABI the same way it does for any Zend-based loader. Rather than take that on faith, the exact same original fixture (§0) was encoded separately for PHP 8.0, 8.1, 8.2, 8.3, 8.4, and 8.5, and each output was run through the unmodified §01–§08 pipeline.

Result: identical framing, one real version-specific wrinkle

§01–§07 succeeded on all six — same key (eval-0x09), same preheader shape, both checksum gates passing, LZO1X inflating cleanly — every time. Container framing (the 15-byte magic, the ver/flags header, the function-table tag/key-length/key layout) was byte-for-byte identical on 8.1, 8.3, 8.4, and 8.5; 8.2 carries the same framing but its internal literal/count encoding starts to diverge a little earlier than the others (expected — it's exactly the kind of small per-ABI shift the ionCube companion research also found between adjacent PHP versions, not a format break). All five reach the same known §08 boundary — the float-literal edge case from §0 — at the identical byte offset, which is itself useful confirmation that it's one shared root cause rather than something reintroduced per build.

PHP 8.0 was the outlier: its container includes a populated version_gate extension block (a 0xF0 marker followed by a 41-byte payload containing the function's exact-case name a second time) that 8.1 and later skip entirely — on every other tested build that gate is empty (immediate 0xFF, no payload). §01–§07 still complete correctly for 8.0 since that block sits inside the already-decrypted, already-inflated body; full §08 parsing for 8.0 specifically needs that extension block decoded first, which is not yet done.

Target (real fixture)§01–§07 (container/crypto)§08 (bytecode container)
PHP 8.0 / Windowsconfirmedblocked — extra version_gate block, unparsed
PHP 8.1 / Windowsconfirmedconfirmed to the known float-literal boundary
PHP 8.2 / Windowsconfirmedsame boundary; internal encoding diverges from 8.1 earlier
PHP 8.3 / Windowsconfirmedsame boundary, framing byte-identical to 8.1
PHP 8.4 / Windowsconfirmedsame boundary, framing byte-identical to 8.1
PHP 8.5 / Windowsconfirmedsame boundary, framing byte-identical to 8.1
cov-yes = confirmed against a real encoded fixture · cov-stub = decrypts correctly, parsing incomplete

Older builds and other platforms: constants only, not yet a fixture

repeatable per-build steps
1. Obtain ixed.<X>.<Y>.<platform> for the target PHP version directly from
   SourceGuardian (e.g. ixed.7.4.win, ixed.8.1.lin, ixed.8.1.dar)
2. Run sg_key_extractor.py against it — same NULL-terminated 3-key array shape
3. Confirm §01–§05 framing constants (preheader tags, section-header layout,
   ROL1 algorithm) are unchanged — they were on every build inspected so far
4. Swap in that PHP version's own Zend/zend_vm_opcodes.h opcode table for §08
5. Validate against a fixture you control, the same way this article does
⚠️
Running step 2 against SourceGuardian's official, freely-distributed loader bundle — every Windows build from PHP 5.3 through 8.5, thread-safe and non-thread-safe, 30 binaries in total — turned up the same three Mode-A key strings, the same standard Blowfish pi-constants, and the same SOURCEGUARDIAN\x00 container magic in every single one. That's a static-binary check, not a decode — but it means the commercial-mode confidentiality weakness from §0/§06 isn't specific to any one build; the same key set appears to decrypt commercially-encoded files across SourceGuardian's entire 17.0 release line, spanning well over a decade of PHP versions. As with the rest of this article, the actual key values found are not reproduced here — only the fact and the extraction method are.

PHP 5.5 and 7.4: a second, older fixture closes most of the gap

article_fixture.php can't compile on anything before PHP 7.4 (typed properties) or below 8.0 (match), so testing the pre-8.0 range needed a second fixture — the same ShoppingCart logic, rewritten without either feature (untyped properties, if/elseif instead of match). That variant was encoded separately for PHP 5.5 and PHP 7.4 — the oldest and newest versions this SourceGuardian PRO 17.0 evaluation's encoder supports below 8.0 — and run through the same unmodified pipeline.

Target (legacy fixture)§01–§07§08 container shape
PHP 5.5 / Windowsconfirmedflags=0x3 (not 0x7) · class table before function table · populated version_gate
PHP 7.4 / Windowsconfirmedflags=0x7, function table first, matching 8.x · but still a populated version_gate (37 B, vs. 41 B on 8.0)
cov-yes = confirmed against a real encoded fixture · cov-stub = decrypts correctly, §08 shape differs from 8.1+

That reframes the §08 "PHP 8.0 wrinkle" from §10's headline: the populated version_gate block isn't an 8.0-only quirk, it's present on every tested build below 8.1 — 5.5, 7.4, and 8.0 all have it; only 8.1 and later ship with it empty. 5.5 carries one additional difference the others don't: a different flags value and class-table-before-function-table ordering, which 7.4 and 8.0 have already dropped in favor of the 8.x convention. Somewhere between 5.5 and 7.4 SourceGuardian normalized the table order and flags but kept the version-gate payload one release longer, until 8.1. Nothing in 5.6–7.3 has been checked, so exactly where that first switch happened is unresolved.

What's still genuinely untested

TargetContainer/crypto constantsReal-fixture decode
PHP 5.3–5.4 / Windowsconfirmed identicalencoder doesn't support these versions
PHP 5.6, 7.0–7.3 / Windowsconfirmed identicalno fixture run yet — bounded by 5.5 and 7.4 above
PHP 5.5–8.5 / Linux, macOSmethod appliesno binaries yet
cov-yes = confirmed on real binaries · cov-stub = documented method, not yet executed against that build

5.3 and 5.4 aren't just untested, they're untestable with this toolchain — the evaluation encoder used throughout this article only targets PHP 5.5 and up. The 5.6–7.3 gap is ordinary unfinished work: the loader constants already match, and closing it means encoding the same legacy fixture on each of those five versions the same way 5.5 and 7.4 were, not new research.


11 Pipeline Summary

End-to-end static decode pipeline
article_fixture.php
  │
  ├─ locate the last sg_load('...') call, base64-decode the argument
  ├─ drop 12-byte machine-signature prefix → buf
  ├─ parse preheader TLV → ver, v58 (metadata section size)
  │
  ├─ try up to 258 key candidates (3 fixed + 255 derived) against the metadata section
  ├─ Blowfish-CBC-IV0 decrypt with the matching key           ← gate: ROL1 checksum
  ├─ LZO1X inflate → metadata.bin (regkey, license fields)
  │
  ├─ locate body TLV at meta_off + v58, Blowfish-CBC-IV0 decrypt with the same key
  ├─ LZO1X inflate                                             ← gate: ROL1 checksum
  ├─ verify "SOURCEGUARDIAN\x00" magic → body.bin
  │
  ├─ parse container: function table, class table, main op_array
  ├─ resolve opcodes against the PHP 8.1 zend_vm_opcodes.h table
  ├─ resolve literals, compiled-variable names, jump targets
  │
  └─ emit readable opcode listing (.icdump.txt) + normalized JSON IR

Encryption layer stack

Each layer wraps everything below it; the ROL1 checksum at the Blowfish boundary is the correctness signal used to confirm the key search landed on the right candidate before spending time on LZO1X or container parsing:

Layer 1  ·  sg_load() wrapper
PHP stub  ·  base64 payload to end of argument  ·  12-byte machine-signature prefix dropped
Layer 2  ·  preheader TLV (plaintext)
tags 0x00/0x04/0x80/0xFF  ·  ~24 bytes  ·  yields v58 (metadata section size)
Layer 3  ·  Blowfish-CBC, IV=(0,0)
standard pi-constants  ·  key = 1 of 258 candidates (§06)
Layer 4  ·  ROL1 checksum gate + LZO1X inflate
rejects wrong keys from the first 12 bytes  ·  minilzo-style decompressor
metadata (regkey/license TLV) + body (SOURCEGUARDIAN container)
op_array structure, opcodes, literals, CV names — fully recoverable

Decode pipeline — visual

article_fixture.php (encoded) — input
PHP stub + sg_load('...') call single base64 argument
01Container Decode
base64 decode drop 12-byte machine signature parse preheader TLV → v58
02Key Resolution
3 commercial + 255 eval-derived candidates ROL1 checksum match
03Blowfish-CBC + LZO1X
decrypt metadata + body sections LZO1X inflate meta.bin / body.bin
04Container Parse
SOURCEGUARDIAN magic function / class / main op_array literals, CV names, jump targets
05Output
readable opcode listing normalized JSON IR

12 Conclusion

SourceGuardian's protection is a straightforward layered stack: a plaintext TLV preheader, standard Blowfish-CBC for confidentiality, a rotate-add checksum for integrity, LZO1X for size, and a fairly direct serialization of zend_op_array that reuses PHP's own opcode numbering rather than inventing a new one. None of the individual primitives are unusual — what makes the format opaque in practice is simply not knowing the container layout and the key, both of which static analysis of the loader recovers completely.

The more interesting finding is architectural rather than cryptographic: commercial deployments don't get a fresh key per file or per customer — they share one of three keys baked into every copy of the loader. That's a design choice about key management, not a flaw in Blowfish itself, and it's the kind of thing worth documenting precisely because the cipher is otherwise unremarkable.

None of that had to be taken on faith: the same fixture logic, encoded separately for eight PHP builds spanning 5.5 through 8.5, decoded correctly through §01–§07 on every single one with the identical Python. §08 is where the real differences showed up, and they layer cleanly by age rather than looking random: 8.1 through 8.5 share one container shape; 7.4 and 8.0 share a second shape that still carries an extra version_gate payload block 8.1 dropped; and 5.5 adds a third wrinkle on top of that — reordered tables and a different flags value — that 7.4 has already left behind (§10). That's a more useful outcome than either finding nothing (which would just mean the fixture wasn't exercising anything version-specific) or finding the format falling apart per build with no pattern. Three shapes bounded by real PHP-version cutoffs is what "the format travels" actually looks like when it's checked rather than assumed.

Porting to a new loader build means re-running the §10 checklist: pull that build's three key strings, confirm the framing constants, and swap in the matching PHP version's own opcode table. The checksum gate at the Blowfish boundary and the magic-byte check at the container boundary are what validate each new build was ported correctly.