Research · Reverse Engineering
SourceGuardian PHP 5.5–8.5 — Static Container & Bytecode Recovery Without Execution
§ 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.
<?php ... sg_load('21311D57C7C4F42FAAQAAAAhAAAABLAAAACABAAAAAAAAAD/05nFSZ/7E6ardYHCSGWAYBcD4TybWIKwUPZOSdNzJIwkZXDjr9xoOnWWeKaSEq35t1lmKs/9Piz2R0DVxZCXE7fm645PncaKO4Dm70a3PXV7dRUr5eh+iOo09d+Q5iGg8XVLMPZQLl17SUr7bX9E6jlL3yezshQIe4MYLNu9g3xD5aE2fxV1BluNpzYpkQcBwrWgt0kK0JkbuqNo/lPlWxpO8yshgmji6k0WtfTpspVSAAAAmAsAAOxDp8ZaKNRdIBFEkhe2F/d1P95rdHk4UI0tPXb1qjTxfRhyfBr9TudO4W3Isk8Eww7q5J8ytnDvKdvJfGWEJL94qDV/OGDMgYj2GnQe2lGQQluGAcCa9QFrSz9jWl2/zivYXF44qPCwTPzxFy/kyRh5HEFfIJ7r37WEGhCVca976Q65/E+1W39u1Mdv…' /* 15,608 more characters */) ?>
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.
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
| Layer | Status 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 gate | confirmed on all six versions |
| LZO1X decompression | confirmed on all six versions |
| Key resolution (commercial + eval-derived) | confirmed, method documented |
SOURCEGUARDIAN container → zend_op_array | confirmed — 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.*.winloader orsgencoder*.dllencoder — 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 secondarticle_fixture_legacy.phpwith 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.
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():
<?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 '):
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:
[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
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:
| Tag | Payload | Meaning |
|---|---|---|
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) |
0xFF | — | end of preheader (~24 bytes total) |
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.
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.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:
[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:
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]
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:
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:
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)
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:
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
Finding them yourself, against your own evaluation copy
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):
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:
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
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:
\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
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:
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:
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')
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:
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 / address | Role |
|---|---|
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_100035D0 | Blowfish key-schedule / CBC encrypt — inverse of §03 |
sub_10005090 | LZO1X compress — inverse of §05 |
sub_10004FB0 | ROL1 checksum, encoder side — same algorithm as §04 |
sub_1000B680 | zend_op_array → SOURCEGUARDIAN 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.
| File | Role |
|---|---|
| sg_key_extractor.py | Scans 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.py | sg_load parsing, preheader TLV, Blowfish-CBC-IV0, ROL1 gate, key search (§01–§07) — writes *.meta.bin / *.body.bin |
| sg_lzo1x.py | Standalone LZO1X decompressor (§05) |
| sg_bytecode_ir.py | SOURCEGUARDIAN 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
# 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:
[article_fixture] key=eval-0x09 preheader(ver=33 v58=176) meta=161B body=6807B
| Field | Value |
|---|---|
| matched key candidate | eval-0x09 — the 10th of 258 tried, confirming this file uses Mode B (§06) |
| metadata section | 169 B ciphertext → 161 B plaintext, checksum OK |
| body section | 2 964 B ciphertext → 6 807 B plaintext, checksum OK |
| container header | ver=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 |
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.
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 / Windows | confirmed | blocked — extra version_gate block, unparsed |
| PHP 8.1 / Windows | confirmed | confirmed to the known float-literal boundary |
| PHP 8.2 / Windows | confirmed | same boundary; internal encoding diverges from 8.1 earlier |
| PHP 8.3 / Windows | confirmed | same boundary, framing byte-identical to 8.1 |
| PHP 8.4 / Windows | confirmed | same boundary, framing byte-identical to 8.1 |
| PHP 8.5 / Windows | confirmed | same boundary, framing byte-identical to 8.1 |
Older builds and other platforms: constants only, not yet a fixture
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
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 / Windows | confirmed | flags=0x3 (not 0x7) · class table before function table · populated version_gate |
| PHP 7.4 / Windows | confirmed | flags=0x7, function table first, matching 8.x · but still a populated version_gate (37 B, vs. 41 B on 8.0) |
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
| Target | Container/crypto constants | Real-fixture decode |
|---|---|---|
| PHP 5.3–5.4 / Windows | confirmed identical | encoder doesn't support these versions |
| PHP 5.6, 7.0–7.3 / Windows | confirmed identical | no fixture run yet — bounded by 5.5 and 7.4 above |
| PHP 5.5–8.5 / Linux, macOS | method applies | no binaries yet |
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
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:
Decode pipeline — visual
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.