Packet Tracer legacy .pkt / .pka container format¶
Status: SOLVED. The legacy container is recovered and verified byte-exact on all
193 staged samples (roundtrip_ok / 193). The transform is simpler than the modern
one: it is exactly the modern pipeline with the outer scramble and the AEAD seal
removed.
Companion artefacts:
tools/legacy_probe.py ·
modern format: FORMAT.md ·
modern codec: pt9.py
Covered here: the 193 real sample files in
corpus/internet/ whose decode_status in
corpus/CORPUS_INDEX.tsv is not OK — i.e. every
file that pt9.py rejects with EAX tag mismatch. (A 194th non-OK row,
bbent-cis202__AllPkaFiles.zip, is a provenance ZIP archive, not a container; it
begins 50 4b 03 04 and is out of scope.)
1. High level¶
A legacy .pkt/.pka file is, from the outside in:
┌─────────────────────────────────────────────────────────────┐
│ Util::deobfuscateBytes : XOR with (len - i) │ "deobfuscate"
├─────────────────────────────────────────────────────────────┤
│ qCompress framing : 4 byte big-endian length + zlib stream │ Qt
├─────────────────────────────────────────────────────────────┤
│ UTF-8 XML "<PACKETTRACER_ACTIVITY>…</PACKETTRACER_ACTIVITY>"│
└─────────────────────────────────────────────────────────────┘
Compare with the modern container (PT 5.3.3 → 9):
┌─────────────────────────────────────────────────────────────┐
│ layer A : byte reversal + position dependent XOR ◄── ABSENT in legacy
├─────────────────────────────────────────────────────────────┤
│ EAX(block cipher) : authenticated encryption + 16 B tag ◄── ABSENT in legacy
├─────────────────────────────────────────────────────────────┤
│ layer B : XOR with (len - i) (Util::deobfuscateBytes) │ identical
├─────────────────────────────────────────────────────────────┤
│ qCompress framing : 4 byte big-endian length + zlib stream │ identical
├─────────────────────────────────────────────────────────────┤
│ UTF-8 XML │
└─────────────────────────────────────────────────────────────┘
The legacy container is Util::deobfuscateBytes(qCompress(xml)). Nothing else.
There is no cipher, no key, no IV, no tag and no byte reversal. The inner
qCompress framing and Util::deobfuscateBytes are shared with the modern
container — those two layers never changed.
As with the modern family there is no magic header: the first bytes of a legacy
file are qCompress bytes XORed with a position-dependent mask, i.e. statistically
random. Detection must therefore be verifier-based, never a prefix match.
2. The transform¶
2.1 Pseudocode¶
BLOCK = 16 # unused -- there is no block cipher in this format
def deobfuscate(buf): # Util::deobfuscateBytes -- an INVOLUTION
n = len(buf)
return bytes(buf[i] ^ ((n - i) & 0xFF) for i in range(n))
obfuscate = deobfuscate # same function re-applies it
def quncompress(payload): # Qt qUncompress
length = BE32(payload[0:4])
xml = zlib.decompress(payload[4:])
assert length == len(xml)
return xml
def qcompress(xml): # Qt qCompress (zlib default level)
return BE32(len(xml)) + zlib.compress(xml)
# ---- read path -------------------------------------------------
def decode(raw):
blob = deobfuscate(raw) # n = len(raw) = len(blob)
xml = quncompress(blob)
return xml
# ---- write path ------------------------------------------------
def encode(xml):
return obfuscate(qcompress(xml))
2.2 Formulas¶
For a file of length n, raw bytes F[0..n-1], and Q = qCompress(xml):
where
Q[0..3] = uncompressed XML length, big-endian uint32
Q[4..] = zlib stream (deflate, default level; CMF/FLG typically 78 9c)
and n == len(Q), i.e. the file length equals the qCompress payload length exactly
— there is no framing, padding, header or trailer around the blob.
Both directions are byte-exact:
2.3 Note on Util::deobfuscateBytes¶
The mask is exactly the same function the modern container calls layer B
(pt9.py::_layer_b), applied to a different buffer:
| Container | buffer being deobfuscated | length used in the mask |
|---|---|---|
| modern | the EAX plaintext | n - 16 (the AEAD plaintext length) |
| legacy | the whole file | n (the file length) |
This is consistent: Util::deobfuscateBytes(QByteArray &) XORs byte i with
(len - i) & 0xff using the length of the buffer it is given. Modern hands it the
AEAD plaintext; legacy hands it the raw file. The function is an involution, so the
same routine writes the file.
3. Verifier / detection rule¶
Because there is no magic prefix, a candidate buffer is accepted only when all three hold:
Q = deobfuscate(F)starts with a sane 4-byte BE lengthL(4 <= L <= 512 MiB) and a valid zlib CMF/FLG pair (CMF == 0x78,(CMF<<8 | FLG) % 31 == 0);zlib.decompress(Q[4:])succeeds and returns exactlyLbytes;encode(xml) == Fbyte-for-byte, and the result has a Packet-Tracer-shaped root element (<PACKETTRACER…>).
Conditions 1+2 alone already have a negligible false-positive rate (a 4-byte length match plus a successful inflate to exactly that length). Condition 3 is a full byte-exact re-encode, which is decisive.
"Well-formed XML" caveat. Packet Tracer documents are XML-shaped, not always XML-1.0-well-formed. Router/switch running configs stored in
<LINE>elements contain raw control bytes — notably0x03, the Cisco IOSbanner motddelimiter, e.g.<LINE>banner motd \x03wtf\x03</LINE>. PT 4.1 documents additionally end in a single trailing0x00. This is not a legacy quirk: 210 of 400 modern files in the same corpus are equally "not well-formed" for the same reason. The correct acceptance criterion is "Packet Tracer document with a root element", which is whatlegacy_probe.pyuses.
4. Hypothesis ladder — pass / fail per rung¶
Rungs are listed as in the attack plan. Every rung used an independent integrity oracle; the winning rung's oracle is described in §3.
| Rung | Hypothesis | Result | Evidence |
|---|---|---|---|
| L1 | Same layers A+B + EAX, different shipped (cipher, keyByte, ivByte) (Twofish 0xba/0xbe, Serpent 0xab/0x23, Serpent 0xab/0xcd, CAST256 0x12/0xfe, 0xf1/0x1f) |
fail | Not applicable: rung L3 proves the file contains no ciphertext layer at all — deobfuscate(F) is already the qCompress framing, so there is nothing for an AEAD to decrypt. Independently, deep_eax_sweep confirms 0 surviving (keyByte, ivByte) pairs under the full 2^-128 tag oracle. |
| L2 | Same AEAD, any repeated-byte key and IV (256 × 256 per cipher) | fail | 65,536 pairs × {Twofish, AES} × {identity layer-A, modern layer-A} = 262,144 trials, 0 EAX-tag confirmations on a representative legacy file. (Self-test: the same sweep on a modern file recovers exactly (0x89, 0x10) as the sole tag-confirmed pair — see §5.3.) |
| L3 | No AEAD at all — layers A+B then qCompress | PASS | More precisely: no layer A, Util::deobfuscateBytes then qCompress. roundtrip_ok / 193. |
| L4 | No AEAD, different obfuscation (reversal and/or different positional XOR) | fail / not needed | 16 positional masks × 2 reorders × 2 operation orders were enumerated; only (as-is, n-i) satisfies the oracle. All other combinations fail on the zlib header / BE length. |
| L5 | Legacy block cipher (Blowfish/DES/TEA/RC4/AES) then L3 check | fail / not needed | L3 already recovers the document with no cipher; a cipher layer would make the direct deobfuscation non-inflatable. |
| L6 | Inner document differs from modern | partly | Root element is not unique to the legacy container — see §6. The document schema generation does differ (PT 4.x PACKETTRACER_ACTIVITY). |
4.1 Why rung L3 is not a false positive¶
The single most important control: Util::deobfuscateBytes is an involution, so
any buffer "decodes" to something. What makes the hit real is that the deobfuscated
buffer
- begins
BE32(L) 78 9c(or another legal zlib CMF/FLG), - inflates to exactly
Lbytes, - begins with
<PACKETTRACER…>and ends with the matching close tag, and - re-compresses with
zlib.compressto a byte-identical qCompress payload, which re-obfuscated is byte-identical to the input file.
That last point is the strongest form of round-trip: it is not merely
f(f(x)) == x (which holds for any involution), it is
encode(decode(x)) == x through the compressor, which pins the exact
uncompressed document and the exact zlib parameters. It holds for 193/193.
5. Verification evidence¶
5.1 Positive proof — 193/193¶
$ python3 tools/legacy_probe.py verify --index corpus/CORPUS_INDEX.tsv --dir corpus/internet
roundtrip_ok / 193
root tags:
PACKETTRACER5 39
PACKETTRACER5_ACTIVITY 90
PACKETTRACER_ACTIVITY 64
versions:
4.1 64
4.2 3
5.1 22
5.2.0.0068 27
5.2.1.0008 34
5.3.0.0088 29
5.3.1.0044 3
5.3.2.0027 11
real 0m2.077s
roundtrip_ok / 193 means: every file decodes, the qCompress length header matches
the inflated size exactly, the document has a Packet Tracer root element, and
re-encoding reproduces the input byte-for-byte.
5.2 Negative controls — zero false positives in both directions¶
| Test | Result |
|---|---|
| legacy decoder applied to all 865 modern-container files | 0 accepted |
modern decoder (pt9.decode_bytes) applied to all 193 legacy files |
0 accepted |
| root element used as a container discriminator | invalid — see §6 |
So the two containers are cleanly disjoint and detection is unambiguous.
5.3 Self-test of the L2 sweep (the oracle is calibrated)¶
Running the 65,536-point (keyByte, ivByte) sweep on a modern sample
(4lban__CCENT-pkt-Labs__Basic.pkt):
$ python3 tools/legacy_probe.py probe "corpus/internet/4lban__CCENT-pkt-Labs__Basic.pkt" --deep-eax
[L2 ] layer-A = identity (sealed = file) / twofish
pre-filter survivors : 1/65536 -> (0x63,0x71)
EAX tag confirmed : 0 -> none
[L2 ] layer-A = modern scramble / twofish
pre-filter survivors : 3/65536 -> (0x45,0xb3), (0x89,0x10), (0xf6,0xe3)
EAX tag confirmed : 1 -> (0x89,0x10)
The 2-byte zlib pre-filter is a necessary condition and leaves ~1 accidental hit per
65,536 (observed 1–4); the full 16-byte EAX tag then selects exactly one pair —
(0x89, 0x10), the known modern constants. The same machinery, applied to a legacy
file, returns 0 tag confirmations across 262,144 trials:
$ python3 tools/legacy_probe.py probe "corpus/internet/bbent-cis202-PTA2011__1.7.1-Intro.pka" --deep-eax
[L3] PASS No AEAD: Util::deobfuscateBytes then qCompress
root=<PACKETTRACER_ACTIVITY> version=4.1
[L2 ] layer-A = identity (sealed = file) / twofish
pre-filter survivors : 1/65536 -> (0x20,0x4f)
EAX tag confirmed : 0 -> none
[L2 ] layer-A = identity (sealed = file) / aes
pre-filter survivors : 4/65536 -> (0x0b,0x6d), (0x2c,0x15), (0x85,0x4a), (0xcd,0x57)
EAX tag confirmed : 0 -> none
[L2 ] layer-A = modern scramble / twofish
pre-filter survivors : 1/65536 -> (0x82,0x28)
EAX tag confirmed : 0 -> none
[L2 ] layer-A = modern scramble / aes
pre-filter survivors : 2/65536 -> (0x02,0x9f), (0xf3,0xb4)
EAX tag confirmed : 0 -> none
5.4 Worked example¶
$ python3 tools/legacy_probe.py info "corpus/internet/abderaoufsec__CCNA-PacketTracerlabs__1.5.1.2 100%.pka"
file : …/abderaoufsec__CCNA-PacketTracerlabs__1.5.1.2 100%.pka
container : legacy (pre-5.3.3, no AEAD)
root tag : PACKETTRACER5_ACTIVITY
<VERSION> : 5.2.1.0008
xml bytes : 1411551
trailing NUL : False
round-trip : OK
Decoded head of bbent-cis202-PTA2011__1.7.1-Intro.pka (PT 4.1, 51,234 bytes in →
730,496 bytes of XML out, a 14× expansion):
<PACKETTRACER_ACTIVITY>
<VERSION>4.1</VERSION>
<PACKETTRACER>
<VERSION>4.1</VERSION>
<NETWORK>
<DEVICES>
<DEVICE>
<ENGINE>
<TYPE model="1841" >Router</TYPE>
<NAME translate="true" >R1-ISP</NAME>
<POWER>true</POWER>
and its tail, showing the PT 4.1 trailing NUL:
6. Inner document taxonomy¶
6.1 Root element — not a container discriminator¶
| Root element | Legacy container | Modern container |
|---|---|---|
PACKETTRACER_ACTIVITY |
64 | 0 |
PACKETTRACER5_ACTIVITY |
90 | 214 |
PACKETTRACER5 |
39 | 505 |
PACKETTRACER5 and PACKETTRACER5_ACTIVITY appear in both containers. The
root element tells you the document kind (.pkt lab vs scored .pka activity) and
the schema generation; it does not tell you which container holds it. This
independently confirms the Phase-1 conclusion that detection must be verifier-based.
6.2 Document generations observed inside the legacy container¶
<VERSION> |
Root | n | trailing NUL |
|---|---|---|---|
| 4.1 | PACKETTRACER_ACTIVITY |
64 | yes |
| 4.2 | PACKETTRACER5 / PACKETTRACER5_ACTIVITY |
3 | no |
| 5.1 | PACKETTRACER5 / PACKETTRACER5_ACTIVITY |
22 | no |
| 5.2.0.0068 | PACKETTRACER5 / PACKETTRACER5_ACTIVITY |
27 | no |
| 5.2.1.0008 | PACKETTRACER5_ACTIVITY |
34 | no |
| 5.3.0.0088 | PACKETTRACER5 / PACKETTRACER5_ACTIVITY |
29 | no |
| 5.3.1.0044 | PACKETTRACER5_ACTIVITY |
3 | no |
| 5.3.2.0027 | PACKETTRACER5 / PACKETTRACER5_ACTIVITY |
11 | no |
Two independent structural rules fall out of the element skeleton:
- The root rename
PACKETTRACER*→PACKETTRACER5*happens at 4.2, not at 5.0. All 64 PT 4.1 documents usePACKETTRACER_ACTIVITY; every 4.2+ document already uses thePACKETTRACER5*names. - Activity documents wrap the network document; lab documents do not. This is independent of the version and identical in the modern container:
<PACKETTRACER_ACTIVITY> or <PACKETTRACER5_ACTIVITY>
<VERSION>4.1</VERSION> <VERSION>5.1</VERSION>
<PACKETTRACER> (4.1) <PACKETTRACER5> (4.2+)
<VERSION>4.1</VERSION> <VERSION>5.1</VERSION>
<NETWORK>…</NETWORK> <NETWORK>…</NETWORK>
</PACKETTRACER> </PACKETTRACER5>
<ACTIVITY …>…</ACTIVITY> <ACTIVITY …>…</ACTIVITY>
<VARIABLE_MANAGER>…</VARIABLE_MANAGER> <VARIABLE_MANAGER>…</VARIABLE_MANAGER>
</PACKETTRACER_ACTIVITY> </PACKETTRACER5_ACTIVITY>
vs a plain .pkt lab:
<VARIABLE_MANAGER> is present in 100% of *_ACTIVITY documents and 0% of
PACKETTRACER5 documents, in both containers — a reliable activity/lab marker.
The trailing 0x00 is perfectly correlated with PACKETTRACER_ACTIVITY / 4.1 in this
corpus (64/64), though with only one schema generation represented this is an
observation, not a proven rule.
6.3 Container generation boundary — 5.3.2 / 5.3.3¶
This is the sharpest structural result. Sorting every file by its embedded
<VERSION>:
<VERSION> |
container | n |
|---|---|---|
| 4.1 | legacy | 64 |
| 4.2 | legacy | 3 |
| 5.1 | legacy | 22 |
| 5.2.0.0068 | legacy | 27 |
| 5.2.1.0008 | legacy | 34 |
| 5.3.0.0088 | legacy | 29 |
| 5.3.1.0044 | legacy | 3 |
| 5.3.2.0027 | legacy | 11 |
| 5.3.3.0019 | modern | 23 |
| 6.0.0.0045 … 9.0.1.0858 | modern | 842 |
Zero version overlap. The container is a deterministic function of the Packet
Tracer build that wrote the file. The AEAD + scramble wrapper was introduced in
PT 5.3.3, not PT 6.0 as previously assumed; the earliest modern-container sample
in the corpus is 5.3.3.0019 and the newest legacy-container sample is 5.3.2.0027.
(docs/ANALYSIS_PLAN.md §3 stated "the modern container already existed at PT 6.0";
that is now refined to 5.3.3.)
7. Differences between the two legacy populations¶
The two populations are not different sub-generations of the container — the container transform is identical and byte-exact across all 193. They differ only in which document generations they carry.
7.1 bbent-cis202-PTA2011__* — 60 files (Cisco NetAcad PTA, ZIP dated 2011-08-16)¶
| container | legacy (identical transform) |
| document versions | 4.1 × 54, 5.1 × 6 |
| root elements | PACKETTRACER_ACTIVITY × 54, PACKETTRACER5_ACTIVITY × 6 |
| trailing NUL | yes on all 54 of the 4.1 files |
| expansion ratio | ~14× (51 KB → 730 KB) |
This set is older than its packaging date suggests. The 2011 ZIP contains predominantly PT 4.1 curriculum material; only 6 of the 60 have been re-saved under PT 5.1. So the "2011 = PT 5.x" assumption in the planning doc is wrong: the bundle is largely PT 4.1-era content shipped in 2011.
7.2 abderaoufsec__CCNA-PacketTracerlabs__* legacy subset — 129 files¶
| container | legacy (identical transform) |
| document versions | 4.1 × 10, 4.2 × 3, 5.1 × 12, 5.2.0.0068 × 27, 5.2.1.0008 × 34, 5.3.0.0088 × 29, 5.3.1.0044 × 3, 5.3.2.0027 × 11 |
| root elements | PACKETTRACER5_ACTIVITY × 80, PACKETTRACER5 × 39, PACKETTRACER_ACTIVITY × 10 |
| trailing NUL | only on the 10 PACKETTRACER_ACTIVITY (4.1) files |
This population spans the whole legacy container lifetime (4.1 → 5.3.2) and is therefore the more informative one.
7.3 The A/B differential inside abderaoufsec — a clean version cut¶
The same repository contributes 224 modern-container files alongside the 129 legacy ones. Splitting by container:
| n | <VERSION> range |
|
|---|---|---|
| legacy container | 129 | 4.1 … 5.3.2.0027 |
| modern container | 224 | 5.3.3.0019 … 9.0.1.0858 (218 of 224 are 5.3.3–6.1.1; one straggler re-saved under 9.0.1) |
The two sets are disjoint in version and interleaved through the same exercise
numbering. Concrete pair, both .pka, both scored activities, both root
PACKETTRACER5_ACTIVITY, both from the same author's NA-… / 1.x.y.z series:
| file | container | <VERSION> |
|---|---|---|
1.2.1.7 Packet Tracer - Comparing 2960 and 3560 Switches.pka |
modern | 6.0.1.0011 |
1.5.1.2 100%.pka |
legacy | 5.2.1.0008 |
Note the direction: the lower exercise number is the newer container. Filename,
topic and extension therefore carry no information about the container; only the
embedded <VERSION> does. Same authors, same topic series, same naming conventions —
only the writing Packet Tracer build differs. This is exactly why the corpus pair was
so useful: holding everything else constant isolates the container as a pure function
of <VERSION>.
7.4 Rakibul73__Cisco_Network_Routing_Switching__{1,2,4,8}.pka — 4 files¶
All PT 5.1, root PACKETTRACER5_ACTIVITY, no trailing NUL. Unremarkable; they
sit mid-range in the legacy population.
8. Implementation¶
tools/legacy_probe.py is self-contained and typed.
| Symbol | Purpose |
|---|---|
deobfuscate_bytes / obfuscate_bytes |
Util::deobfuscateBytes, the involution ^ (len - i) |
quncompress / qcompress |
Qt qCompress framing (4-byte BE length + zlib) |
decode_bytes / decode / encode_bytes / encode |
container read / write |
LegacyDocument |
xml, blob, root_tag, version, trailing_nul, is_activity |
roundtrip_ok |
byte-exact encode(decode(f)) == f |
probe_hypotheses |
runs the L1–L6 ladder and reports pass/fail per rung |
eax_tag_ok, deep_eax_sweep, modern_layer_a |
the L2 negative control + self-test |
verify (CLI) |
proves roundtrip_ok / 193 over CORPUS_INDEX.tsv |
CLI:
python3 tools/legacy_probe.py decode FILE [-o OUT.xml]
python3 tools/legacy_probe.py info FILE
python3 tools/legacy_probe.py encode FILE.xml -o OUT.pkt
python3 tools/legacy_probe.py probe FILE [--deep-eax]
python3 tools/legacy_probe.py verify --index corpus/CORPUS_INDEX.tsv --dir corpus/internet
It deliberately does not import pt9.py; the shared primitives
(deobfuscateBytes, qCompress) are reimplemented so the module can be lifted into
pkt_core independently. The one dependency beyond the standard library is
libgcrypt.so.20, used only by the optional --deep-eax negative control.
9. Residual unknowns¶
These do not affect decode/encode correctness, which is byte-exact:
- Trailing NUL rule. Perfectly correlated with PT 4.1 /
PACKETTRACER_ACTIVITYin this corpus (64/64), but only one schema generation is represented, so it is not established as a general rule. A 4.0-era sample would settle it. - Whether any PT build writes the legacy container with a non-default zlib
level. All 193 samples recompress at
zlib.compress(xml, -1)to byte-identical output, so no sample exercises another level. - PT 4.0 and earlier. The oldest document present is 4.1. Whether a 3.x/4.0 container exists with a further-removed transform is untested.
.pks/.pktvariants and the otherUtil::cipher table entries (log, PKC, PTA, simulation, user-app). Those are separate formats, not this container, and remain out of scope here.