From 68b69a8bd1fd0512cbb42aad647940cac4504f32 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Wed, 8 Apr 2026 16:00:56 -0300 Subject: [PATCH] add initial implementation of GCNet packet decryptor with pcapng support --- .gitignore | 72 +++++ README.md | 120 ++++++++ docs/ADDING_PACKET_PARSERS.md | 274 ++++++++++++++++++ pom.xml | 95 ++++++ .../java/com/gcemu/gcpp/CliArguments.java | 36 +++ .../java/com/gcemu/gcpp/GCPacketParser.java | 88 ++++++ .../java/com/gcemu/gcpp/OutputFormatter.java | 151 ++++++++++ .../java/com/gcemu/gcpp/PacketExtractor.java | 40 +++ .../java/com/gcemu/gcpp/PacketProcessor.java | 94 ++++++ .../gcpp/packets/CompressedPayloadParser.java | 3 + .../java/com/gcemu/gcpp/packets/Opcode.java | 39 +++ .../com/gcemu/gcpp/packets/PacketContext.java | 82 ++++++ .../com/gcemu/gcpp/packets/PacketParser.java | 59 ++++ .../gcpp/packets/PacketParserFactory.java | 28 ++ .../gcpp/packets/PayloadContentExtractor.java | 107 +++++++ .../packets/parsers/GenericPayloadParser.java | 37 +++ .../gcpp/packets/parsers/HeartBeatParser.java | 30 ++ .../packets/parsers/KeyExchangeParser.java | 92 ++++++ .../parsers/VerifyAccountReqParser.java | 80 +++++ .../com/gcemu/gcpp/pcapng/PcapngParser.java | 32 ++ .../gcemu/gcpp/pcapng/TcpPacketParser.java | 172 +++++++++++ .../gcpp/security/SecurityAssociation.java | 193 ++++++++++++ 22 files changed, 1924 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/ADDING_PACKET_PARSERS.md create mode 100644 pom.xml create mode 100644 src/main/java/com/gcemu/gcpp/CliArguments.java create mode 100644 src/main/java/com/gcemu/gcpp/GCPacketParser.java create mode 100644 src/main/java/com/gcemu/gcpp/OutputFormatter.java create mode 100644 src/main/java/com/gcemu/gcpp/PacketExtractor.java create mode 100644 src/main/java/com/gcemu/gcpp/PacketProcessor.java create mode 100644 src/main/java/com/gcemu/gcpp/packets/CompressedPayloadParser.java create mode 100644 src/main/java/com/gcemu/gcpp/packets/Opcode.java create mode 100644 src/main/java/com/gcemu/gcpp/packets/PacketContext.java create mode 100644 src/main/java/com/gcemu/gcpp/packets/PacketParser.java create mode 100644 src/main/java/com/gcemu/gcpp/packets/PacketParserFactory.java create mode 100644 src/main/java/com/gcemu/gcpp/packets/PayloadContentExtractor.java create mode 100644 src/main/java/com/gcemu/gcpp/packets/parsers/GenericPayloadParser.java create mode 100644 src/main/java/com/gcemu/gcpp/packets/parsers/HeartBeatParser.java create mode 100644 src/main/java/com/gcemu/gcpp/packets/parsers/KeyExchangeParser.java create mode 100644 src/main/java/com/gcemu/gcpp/packets/parsers/VerifyAccountReqParser.java create mode 100644 src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java create mode 100644 src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java create mode 100644 src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7f98a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# ===================== +# Java / Maven +# ===================== +target/ +*.class +*.jar +*.war +*.ear +*.log +*.tmp + +# Maven +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties + +# ===================== +# IDE - JetBrains (IntelliJ, etc.) +# ===================== +.idea/ +*.iws +*.iml +*.ipr +out/ + +# Eclipse +.classpath +.project +.settings/ +bin/ + +# VS Code +.vscode/ + +# NetBeans +nbproject/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ + +# ===================== +# OS Files +# ===================== +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# ===================== +# Test / Capture Files +# ===================== +*.pcapng +*.pcap +*.cap +*.dump + +# ===================== +# Miscellaneous +# ===================== +*.swp +*.swo +*~ +.recommenders diff --git a/README.md b/README.md new file mode 100644 index 0000000..6228d62 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# GCEmu Packet Parser + +A Java tool to parse and analyze Grand Chase packets from pcapng capture files. + +## Overview + +This tool reads pcapng files containing network captures of Grand Chase game traffic, filters TCP packets on a specified port (default: 9501), and decrypts them. It automatically: + +1. Parses pcapng file format +2. Extracts TCP segments and filters by port +3. Detects the initial key exchange packet (opcode 1) to obtain session keys +4. Decrypts all subsequent packets +5. Validates packet integrity +6. Decompresses compressed payloads +7. Displays decrypted packet contents in human-readable format + +## Building + +```bash +mvn clean package +``` + +This creates two JAR files in `target/`: +- `gcpp-1.0.0.jar` - Standalone JAR (requires dependencies) +- `gcpp-1.0.0-jar-with-dependencies.jar` - Fat JAR with all dependencies (recommended) + +## Usage + +```bash +java -jar target/gcpp-1.0.0-jar-with-dependencies.jar [port] +``` + +**Parameters:** +- ``: Path to the pcapng capture file (required) +- `[port]`: TCP port to filter on (default: 9501) + +**Examples:** + +```bash +# Decrypt packets on default port 9501 +java -jar target/gcpp-1.0.0-jar-with-dependencies.jar capture.pcapng + +# Decrypt packets on custom port +java -jar target/gcpp-1.0.0-jar-with-dependencies.jar capture.pcapng 9001 +``` + +## How It Works + +### Grand Chase Protocol Structure + +The Grand Chase protocol has two main layers: + +#### 1. Security Layer +- **Size** (2 bytes): Total security layer size +- **SPI** (2 bytes): Security Parameters Index +- **Sequence Number** (4 bytes): Packet counter +- **IV** (8 bytes): DES initialization vector +- **Encrypted Payload** (variable): DES-CBC encrypted data +- **ICV** (10 bytes): Integrity check value (MD5-HMAC truncated) + +#### 2. Payload Layer +- **Opcode** (2 bytes): Packet type identifier +- **Content Size** (4 bytes): Size of content +- **Compression Flag** (1 byte): Whether content is zlib-compressed +- **Content** (variable): Actual data (possibly compressed) +- **Padding** (4 bytes): End padding + +### Key Exchange + +The first packet (opcode 1) contains the session keys: +- Sent by server using default keys +- Contains new SPI, authentication key, and encryption key +- All subsequent packets use these new keys + +**Default Keys:** +- Encryption Key: `C7 D8 C4 BF B5 E9 C0 FD` +- Authentication Key: `C0 D3 BD C3 B7 CE B8 B8` + +### Encryption + +- **Algorithm**: DES in CBC mode +- **Padding**: Custom padding scheme (incrementing bytes) +- **Integrity**: MD5-HMAC truncated to 10 bytes + +### Compression + +- **Algorithm**: zlib +- **Header**: `78 01` +- **Structure**: First 4 bytes indicate decompressed size (little-endian) + +## Output Format + +For each packet, the tool displays: +- Source/destination IP and port +- TCP sequence number +- SPI and IV values +- ICV validation status +- Opcode and content size +- Hex dump of decrypted content +- Extracted readable strings + +## Project Structure + +``` +gcnet-decryptor/ +├── pom.xml +└── src/main/java/com/gcpp + ├── GCPacketParser.java # Main application + ├── pcapng/ + │ ├── PcapngParser.java # pcapng file parser (wraps pcapngdecoder) + │ └── TcpPacketParser.java # TCP segment extractor + ├── security/ + │ └── SecurityAssociation.java # Decryption & ICV validation + └── payload/ + └── PayloadParser.java # Payload parser & decompression +``` + +## Dependencies + +- **[pcapng-decoder](https://github.com/bertrandmartel/pcapng-decoder)** by Bertrand Martel (MIT License) - Pure Java pcapng file parser diff --git a/docs/ADDING_PACKET_PARSERS.md b/docs/ADDING_PACKET_PARSERS.md new file mode 100644 index 0000000..e56b1f7 --- /dev/null +++ b/docs/ADDING_PACKET_PARSERS.md @@ -0,0 +1,274 @@ +# Adding New Packet Parsers + +This guide explains how to implement parsers for new GCNet packet types. + +## Architecture Overview + +``` +┌──────────────────────────────────────────────────────────────┐ +│ PacketProcessor │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ PacketParserFactory │ │ +│ │ ┌─────────────────┐ ┌────────────────────────────┐ │ │ +│ │ │ KeyExchangeParser│ │ VerifyAccountReqParser │ │ │ +│ │ └─────────────────┘ └────────────────────────────┘ │ │ +│ │ Strategy Pattern │ │ +│ └────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ PacketContext │ +│ │ +│ rawPayload() → full decrypted bytes │ +│ content() → header stripped, decompressed if flagged │ +│ isCompressed() → whether original had compression flag │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +### Design Patterns + +| Pattern | Class | Purpose | +|---------|-------|---------| +| **Strategy** | `PacketParser` | Each opcode has its own parsing algorithm | +| **Factory** | `PacketParserFactory` | Creates the correct parser per opcode | +| **Context** | `PacketContext` | Immutable object with processed content | +| **Registry** | `Opcode` enum | Maps numbers to names + direction hints | +| **Null Object** | `GenericPayloadParser` | Fallback for unimplemented opcodes | +| **Marker** | `CompressedPayloadParser` | Declares a parser needs decompressed content | + +## Step-by-Step: Adding a New Parser + +### 1. Define the Opcode + +```java +// In com.gcemu.gcpp.packets.Opcode + +PLAYER_JOIN_REQ(50, "PLAYER_JOIN_REQ", + "Player join request", Direction.CLIENT_TO_SERVER), +``` + +### 2. Create the Parser + +```java +package com.gcemu.gcpp.packets.parsers; + +import com.gcemu.gcpp.packets.*; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Parser for PLAYER_JOIN_REQ (opcode 50). + * + *

Content Structure (offset 0, header already stripped):

+ * + * + * + * + * + *
OffsetSizeTypeDescription
04int (LE)Username byte length
4varstring (UTF-16LE)Username
var4int (LE)Character class
+ */ +@PacketParser.PacketParserFor(opcode = 50) +public class PlayerJoinReqParser implements PacketParser { + + @Override + public ParseResult parse(PacketContext context) { + // content() = raw payload minus the 7-byte GCNet header. + // If this parser implemented CompressedPayloadParser, the + // content would also be zlib-decompressed automatically. + byte[] content = context.content(); + + if (content.length < 4) { + return ParseResult.empty(Opcode.PLAYER_JOIN_REQ, "Client -> Server"); + } + + int offset = 0; + Map fields = new LinkedHashMap<>(); + + // Parse username length + int nameLen = ByteBuffer.wrap(content, offset, 4) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).getInt(); + offset += 4; + + // Parse username + if (offset + nameLen <= content.length) { + String name = new String(content, offset, nameLen, StandardCharsets.UTF_16LE); + fields.put("Username", name); + offset += nameLen; + } + + // Parse character class + if (offset + 4 <= content.length) { + int charClass = ByteBuffer.wrap(content, offset, 4) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).getInt(); + fields.put("Character Class", String.valueOf(charClass)); + } + + return ParseResult.parsed( + Opcode.PLAYER_JOIN_REQ, + "Client -> Server", + "PLAYER_JOIN_REQ { username: string_utf16, class: int32 }", + fields, + formatHex(content), + "player=\"" + fields.getOrDefault("Username", "?") + "\"" + ); + } + + private String formatHex(byte[] data) { + var sb = new StringBuilder(); + for (int i = 0; i < data.length; i++) { + if (i % 16 == 0 && i > 0) sb.append("\n"); + sb.append(String.format("%02X ", data[i])); + } + return sb.toString().trim(); + } +} +``` + +**Key rules:** +- `context.content()` returns **only content** — no header, no padding +- Start parsing at **offset 0** +- If the packet was compressed and your parser implements `CompressedPayloadParser`, it's **already decompressed** + +### 3. Register the Parser + +```java +// In PacketParserFactory.registerBuiltInParsers() +registerParser(50, new PlayerJoinReqParser()); +``` + +## Handling Compressed Packets + +Some GCNet packets have zlib-compressed content. The framework handles +decompression automatically — you just need to **declare intent**. + +### Add `CompressedPayloadParser` to Your Parser + +```java +package com.gcemu.gcpp.packets.parsers; + +import com.gcemu.gcpp.packets.*; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Parser for a packet with compressed content (e.g., opcode 3). + * + *

By implementing {@link CompressedPayloadParser}, the framework + * decompresses the content before {@code parse()} is called.

+ */ +@PacketParser.PacketParserFor(opcode = 3) +public class ServerContentsParser implements PacketParser, CompressedPayloadParser { + + @Override + public ParseResult parse(PacketContext context) { + // context.content() is already: + // 1. Stripped of the 7-byte GCNet header + // 2. Decompressed via zlib (because compression flag was set) + byte[] content = context.content(); + + // True if the original packet had compression flag = 1 + boolean wasCompressed = context.isCompressed(); + + Map fields = new LinkedHashMap<>(); + fields.put("Was Compressed", String.valueOf(wasCompressed)); + fields.put("Decompressed Size", String.valueOf(content.length)); + + // Parse the decompressed content as needed... + + return ParseResult.parsed( + Opcode.valueOf(3), + resolveDirection(context), + "SERVER_CONTENTS { data: bytes (zlib decompressed) }", + fields, + formatHex(content), + "" + ); + } + + private String resolveDirection(PacketContext ctx) { + return ctx.isClientToServer() ? "Client -> Server" : "Server -> Client"; + } + + private String formatHex(byte[] data) { + var sb = new StringBuilder(); + for (int i = 0; i < data.length; i++) { + if (i % 16 == 0 && i > 0) sb.append("\n"); + sb.append(String.format("%02X ", data[i])); + } + return sb.toString().trim(); + } +} +``` + +### Content Flow + +``` +Wire (encrypted): + [DES-CBC encrypted: header(7) + [decompressedSize(4) + zlibData...]] + │ + PacketProcessor.decryptPacket() ▼ + [opcode:2][size:4][flag:1][decompressedSize:4][zlibData...] + │ + ▼ + PacketParserFactory detects CompressedPayloadParser + (or just strips header if not compressed) + │ + ▼ + PacketContext.content() + [Decompressed content bytes — offset 0] + │ + ▼ + Your parser — parse from offset 0 +``` + +### `CompressedPayloadParser` vs Plain `PacketParser` + +| | Plain `PacketParser` | + `CompressedPayloadParser` | +|---|---|---| +| Header stripping | ❌ No — raw payload | ✅ Stripped | +| Decompression | ❌ No | ✅ Automatic | +| `context.content()` | Returns full raw payload | Returns processed content | +| `context.isCompressed()` | Available | Available | +| Best for | Key exchange, small control packets | Large data packets with compressed content | + +> **Tip:** Always implement `CompressedPayloadParser` for packets that +> may have compressed content. For packets that are never compressed +> (key exchange, heartbeats), a plain `PacketParser` is fine. + +## Direction Detection + +```java +context.isClientToServer() // dstPort == serverPort +context.isServerToClient() // srcPort == serverPort +``` + +For server port 9501: +- `client:51094 -> server:9501` = **Client -> Server** +- `server:9501 -> client:51094` = **Server -> Client** + +## File Structure + +``` +src/main/java/com/gcemu/gcpp/packets/ +├── Opcode.java ← Opcode registry +├── PacketContext.java ← Processed content + direction +├── PacketParser.java ← Strategy interface + annotation +├── CompressedPayloadParser.java ← Marker for compressed content +├── PayloadContentExtractor.java ← Header stripping + zlib decompression +├── PacketParserFactory.java ← Factory + registration +└── parsers/ + ├── GenericPayloadParser.java ← Fallback + ├── HeartBeatParser.java ← Opcode 0 + ├── KeyExchangeParser.java ← Opcode 1 (uses content()) + └── VerifyAccountReqParser.java← Opcode 2 (uses content()) +``` + +## Testing + +```bash +java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar capture.pcapng 9501 +``` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3f291d4 --- /dev/null +++ b/pom.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + com.gcemu + gcpp + 1.0.0 + jar + + + 25 + 25 + UTF-8 + + + + + org.projectlombok + lombok + 1.18.44 + provided + + + fr.bmartel + pcapngdecoder + 1.2 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.44 + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.gcemu.gcpp.GCPacketParser + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.6.0 + + + + com.gcemu.gcpp.GCPacketParser + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + com.diffplug.spotless + spotless-maven-plugin + 3.3.0 + + + + + + + + + diff --git a/src/main/java/com/gcemu/gcpp/CliArguments.java b/src/main/java/com/gcemu/gcpp/CliArguments.java new file mode 100644 index 0000000..85c7420 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/CliArguments.java @@ -0,0 +1,36 @@ +package com.gcemu.gcpp; + +import java.io.File; +import lombok.Getter; + +@Getter +public class CliArguments { + private static final int DEFAULT_PORT = 9501; + + private final File pcapngFile; + private final int targetPort; + + public CliArguments(String[] args) { + if (args.length < 1) { + printUsage(); + System.exit(1); + } + + this.pcapngFile = new File(args[0]); + this.targetPort = args.length > 1 ? Integer.parseInt(args[1]) : DEFAULT_PORT; + } + + public void validate() { + if (!pcapngFile.exists() || !pcapngFile.isFile()) { + System.err.println("Error: File not found or not accessible: " + pcapngFile.getPath()); + System.exit(1); + } + } + + private void printUsage() { + System.out.println("Usage: java -jar gcnet-decryptor.jar [port]"); + System.out.println(" : Path to the pcapng capture file"); + System.out.println(" [port]: TCP port to filter on (default: " + DEFAULT_PORT + ")"); + System.exit(1); + } +} diff --git a/src/main/java/com/gcemu/gcpp/GCPacketParser.java b/src/main/java/com/gcemu/gcpp/GCPacketParser.java new file mode 100644 index 0000000..793c95d --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/GCPacketParser.java @@ -0,0 +1,88 @@ +package com.gcemu.gcpp; + +import com.gcemu.gcpp.pcapng.TcpPacketParser; + +public class GCPacketParser { + private final PacketExtractor extractor = new PacketExtractor(); + private final OutputFormatter formatter = new OutputFormatter(); + + private final CliArguments cliArgs; + private final PacketProcessor processor; + + public GCPacketParser(String[] args) { + this.cliArgs = new CliArguments(args); + this.processor = new PacketProcessor(cliArgs.getTargetPort()); + } + + public void run() { + cliArgs.validate(); + + formatter.printHeader(cliArgs.getPcapngFile().getName(), cliArgs.getTargetPort()); + + try { + var extractionResult = extractor.extract(cliArgs.getPcapngFile(), cliArgs.getTargetPort()); + formatter.printParsingPhase(extractionResult.totalCaptured()); + formatter.printExtractionPhase( + extractionResult.tcpSegmentsFound(), extractionResult.filteredSegments().size()); + + if (extractionResult.filteredSegments().isEmpty()) { + formatter.printNoSegmentsFound(); + return; + } + + formatter.printDecryptionPhase(); + processPackets(extractionResult.filteredSegments()); + + } catch (Exception e) { + System.err.println("Error processing file: " + e.getMessage()); + System.exit(1); + } + } + + private void processPackets(java.util.List segments) { + var packetCount = 0; + var decryptedCount = 0; + var failedCount = 0; + + for (TcpPacketParser.TcpSegment segment : segments) { + packetCount++; + formatter.printPacketHeader(packetCount, segment, segment.payload().length); + + var result = processor.process(segment); + + if (result.isTooSmall()) { + formatter.printSkippedPacket(); + continue; + } + + if (!result.isSuccess()) { + formatter.printDecryptionError(result.errorMessage()); + failedCount++; + System.out.println(); + continue; + } + + formatter.printDecryptionResult(result.decryptionResult()); + + if (result.isKeyExchangeDetected()) { + formatter.printKeyExchangeDetected(); + if (processor.isKeyExchangeProcessed()) { + formatter.printKeyExchangeSuccess(); + } else { + formatter.printKeyExchangeFailure(); + } + } + + formatter.printParsedPacket(result.parseResult()); + decryptedCount++; + System.out.println(); + } + + formatter.printSummary( + packetCount, decryptedCount, failedCount, processor.isKeyExchangeProcessed()); + } + + static void main(String[] args) { + new GCPacketParser(args).run(); + } +} diff --git a/src/main/java/com/gcemu/gcpp/OutputFormatter.java b/src/main/java/com/gcemu/gcpp/OutputFormatter.java new file mode 100644 index 0000000..2eed1aa --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/OutputFormatter.java @@ -0,0 +1,151 @@ +package com.gcemu.gcpp; + +import com.gcemu.gcpp.packets.PacketParser; +import com.gcemu.gcpp.pcapng.TcpPacketParser; +import com.gcemu.gcpp.security.SecurityAssociation; + +/** Handles formatted console output for the parser. */ +public class OutputFormatter { + public void printHeader(String fileName, int targetPort) { + System.out.println("========================================"); + System.out.println("GCEmu Packet Parser"); + System.out.println("========================================"); + System.out.println("Input file: " + fileName); + System.out.println("Target port: " + targetPort); + System.out.println(); + } + + public void printParsingPhase(int totalPackets) { + System.out.println("[1/4] Parsing pcapng file..."); + System.out.println(" Total packets captured: " + totalPackets); + } + + public void printExtractionPhase(int tcpFound, int filteredCount) { + System.out.println("[2/4] Extracting TCP segments..."); + System.out.println(" TCP segments found: " + tcpFound); + System.out.println(" Segments on target port: " + filteredCount); + System.out.println(); + } + + public void printNoSegmentsFound() { + System.out.println("No TCP segments found on target port"); + } + + public void printDecryptionPhase() { + System.out.println("[3/4] Parsing packets..."); + System.out.println("========================================"); + System.out.println(); + } + + public void printPacketHeader( + int packetNum, TcpPacketParser.TcpSegment segment, int payloadSize) { + System.out.println("────────────────────────────────────"); + System.out.println("Packet #" + packetNum); + System.out.println("────────────────────────────────────"); + System.out.println(segment); + System.out.println("TCP Payload Size: " + payloadSize + " bytes"); + } + + public void printSkippedPacket() { + System.out.println(" [SKIP] Too small to be a GCNet packet"); + System.out.println(); + } + + public void printDecryptionResult(SecurityAssociation.DecryptionResult result) { + System.out.println(" SPI: 0x" + String.format("%04X", result.spi())); + System.out.println(" IV: " + bytesToHex(result.iv())); + System.out.println(" ICV Valid: " + result.icvValid()); + System.out.println(" Decrypted Payload Size: " + result.decryptedPayload().length + " bytes"); + } + + public void printKeyExchangeDetected() { + System.out.println(); + System.out.println(" *** KEY EXCHANGE PACKET DETECTED (Opcode 1) ***"); + System.out.println(" Extracting session keys for subsequent packets..."); + System.out.println(); + } + + public void printKeyExchangeSuccess() { + System.out.println(" [OK] Session keys extracted - all following packets will use these"); + } + + public void printKeyExchangeFailure() { + System.out.println(" [WARN] Failed to parse key exchange, using default keys"); + } + + public void printParsedPacket(PacketParser.ParseResult parseResult) { + System.out.println(); + System.out.println(" ┌─────────────────────────────────────────────────"); + System.out.println(" │ Packet: " + parseResult.opcodeName()); + System.out.println( + " │ Opcode: 0x" + + String.format("%04X", parseResult.opcode().getOpcode()) + + " (" + + parseResult.opcode().getOpcode() + + ")"); + System.out.println(" │ Direction: " + parseResult.direction()); + System.out.println(" │ Structure: " + parseResult.structure()); + System.out.println(" ├─────────────────────────────────────────────────"); + + if (!parseResult.fields().isEmpty()) { + System.out.println(" │ Fields:"); + for (var entry : parseResult.fields().entrySet()) { + var value = entry.getValue(); + + // Truncate long values like hex strings + if (value.length() > 64) { + value = value.substring(0, 60) + "..."; + } + + System.out.println(" │ " + entry.getKey() + ": " + value); + } + } + + if (!parseResult.readableText().isEmpty()) { + System.out.println(" ├─────────────────────────────────────────────────"); + System.out.println(" │ Extracted: " + parseResult.readableText()); + } + + if (!parseResult.rawHex().isEmpty()) { + System.out.println(" ├─────────────────────────────────────────────────"); + System.out.println(" │ Raw Hex:"); + + var lines = parseResult.rawHex().split("\n"); + for (var line : lines) { + System.out.println(" │ " + line); + } + } + + System.out.println(" └─────────────────────────────────────────────────"); + } + + public void printDecryptionError(String error) { + System.out.println(" [ERROR] Parsing failed: " + error); + } + + public void printSummary(int total, int decrypted, int failed, boolean keyExchangeProcessed) { + System.out.println("========================================"); + System.out.println("[4/4] Summary"); + System.out.println("========================================"); + System.out.println("Total packets processed: " + total); + System.out.println("Successfully parsed: " + decrypted); + System.out.println("Failed: " + failed); + System.out.println("Key exchange processed: " + keyExchangeProcessed); + System.out.println("========================================"); + } + + public String bytesToHex(byte[] bytes) { + return bytesToHex(bytes, bytes.length); + } + + public String bytesToHex(byte[] bytes, int length) { + var sb = new StringBuilder(); + var end = Math.min(length, bytes.length); + + for (var i = 0; i < end; i++) { + sb.append(String.format("%02X ", bytes[i])); + } + + return sb.toString().trim(); + } +} diff --git a/src/main/java/com/gcemu/gcpp/PacketExtractor.java b/src/main/java/com/gcemu/gcpp/PacketExtractor.java new file mode 100644 index 0000000..6924577 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/PacketExtractor.java @@ -0,0 +1,40 @@ +package com.gcemu.gcpp; + +import com.gcemu.gcpp.pcapng.PcapngParser; +import com.gcemu.gcpp.pcapng.TcpPacketParser; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** Extracts TCP segments from pcapng files filtered by port. */ +public class PacketExtractor { + private final PcapngParser pcapngParser = new PcapngParser(); + private final TcpPacketParser tcpParser = new TcpPacketParser(); + + public ExtractionResult extract(File file, int targetPort) throws Exception { + var rawPackets = pcapngParser.parseFile(file); + + var packetsByLinkLayer = + rawPackets.stream().collect(Collectors.groupingBy(PcapngParser.Packet::linkLayerType)); + + List allTcpSegments = new ArrayList<>(); + for (var entry : packetsByLinkLayer.entrySet()) { + var packets = entry.getValue(); + var segments = + packets.stream() + .map(packet -> tcpParser.parsePacket(packet.packetData())) + .filter(Objects::nonNull) + .toList(); + allTcpSegments.addAll(segments); + } + + var filteredSegments = TcpPacketParser.filterByPort(allTcpSegments, targetPort); + + return new ExtractionResult(rawPackets.size(), allTcpSegments.size(), filteredSegments); + } + + public record ExtractionResult( + int totalCaptured, int tcpSegmentsFound, List filteredSegments) {} +} diff --git a/src/main/java/com/gcemu/gcpp/PacketProcessor.java b/src/main/java/com/gcemu/gcpp/PacketProcessor.java new file mode 100644 index 0000000..9faef78 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/PacketProcessor.java @@ -0,0 +1,94 @@ +package com.gcemu.gcpp; + +import static java.util.Objects.nonNull; + +import com.gcemu.gcpp.packets.CompressedPayloadParser; +import com.gcemu.gcpp.packets.PacketContext; +import com.gcemu.gcpp.packets.PacketParser; +import com.gcemu.gcpp.packets.PacketParserFactory; +import com.gcemu.gcpp.pcapng.TcpPacketParser; +import com.gcemu.gcpp.security.SecurityAssociation; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class PacketProcessor { + private static final int MIN_GCNET_PACKET_SIZE = 28; + + private final PacketParserFactory parserFactory = new PacketParserFactory(); + private SecurityAssociation securityAssociation = new SecurityAssociation(); + private final int serverPort; + + @Getter private boolean keyExchangeProcessed = false; + + public ProcessingResult process(TcpPacketParser.TcpSegment segment) { + var tcpPayload = segment.payload(); + + if (tcpPayload.length < MIN_GCNET_PACKET_SIZE) { + return ProcessingResult.tooSmall(); + } + + try { + var result = securityAssociation.decryptPacket(tcpPayload); + var newKeyExchangeDetected = false; + + // Check for key exchange packet (only process once) + if (!keyExchangeProcessed + && SecurityAssociation.isInitialKeyExchange(result.decryptedPayload())) { + var newAssociation = SecurityAssociation.parseInitialKeyExchange(result.decryptedPayload()); + + if (nonNull(newAssociation)) { + securityAssociation = newAssociation; + keyExchangeProcessed = true; + newKeyExchangeDetected = true; + } + } + + // Determine parser and whether it needs decompressed content + var opcode = resolveOpcode(result.decryptedPayload()); + var parser = parserFactory.getParser(opcode.getOpcode()); + var decompressContent = parser instanceof CompressedPayloadParser; + + // Create context — content() strips header and decompresses if flagged + var context = + new PacketContext(result.decryptedPayload(), segment, serverPort, decompressContent); + var parseResult = parser.parse(context); + + return ProcessingResult.success(result, parseResult, newKeyExchangeDetected); + } catch (Exception e) { + return ProcessingResult.error(e.getMessage()); + } + } + + private com.gcemu.gcpp.packets.Opcode resolveOpcode(byte[] decryptedPayload) { + if (decryptedPayload.length < 2) { + return com.gcemu.gcpp.packets.Opcode.UNKNOWN; + } + + var opcode = ((decryptedPayload[0] & 0xFF) << 8) | (decryptedPayload[1] & 0xFF); + return com.gcemu.gcpp.packets.Opcode.valueOf(opcode); + } + + public record ProcessingResult( + boolean isSuccess, + boolean isTooSmall, + boolean isKeyExchangeDetected, + SecurityAssociation.DecryptionResult decryptionResult, + PacketParser.ParseResult parseResult, + String errorMessage) { + public static ProcessingResult tooSmall() { + return new ProcessingResult(false, true, false, null, null, null); + } + + public static ProcessingResult success( + SecurityAssociation.DecryptionResult decryption, + PacketParser.ParseResult parseResult, + boolean keyExchangeDetected) { + return new ProcessingResult(true, false, keyExchangeDetected, decryption, parseResult, null); + } + + public static ProcessingResult error(String message) { + return new ProcessingResult(false, false, false, null, null, message); + } + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/CompressedPayloadParser.java b/src/main/java/com/gcemu/gcpp/packets/CompressedPayloadParser.java new file mode 100644 index 0000000..0b11169 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/CompressedPayloadParser.java @@ -0,0 +1,3 @@ +package com.gcemu.gcpp.packets; + +public interface CompressedPayloadParser {} diff --git a/src/main/java/com/gcemu/gcpp/packets/Opcode.java b/src/main/java/com/gcemu/gcpp/packets/Opcode.java new file mode 100644 index 0000000..6359a34 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/Opcode.java @@ -0,0 +1,39 @@ +package com.gcemu.gcpp.packets; + +import java.util.HashMap; +import java.util.Map; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Opcode { + KEY_EXCHANGE(1, "KEY_EXCHANGE", "Initial security key exchange", Direction.SERVER_TO_CLIENT), + VERIFY_ACCOUNT_REQ( + 2, "VERIFY_ACCOUNT_REQ", "Client authentication request", Direction.CLIENT_TO_SERVER), + + UNKNOWN(-1, "UNKNOWN", "Unknown opcode", Direction.BIDIRECTIONAL); + + private final int opcode; + private final String name; + private final String description; + private final Direction direction; + + private static final Map OPCODE_MAP = new HashMap<>(); + + static { + for (var opcode : values()) { + OPCODE_MAP.put(opcode.opcode, opcode); + } + } + + public static Opcode valueOf(int opcode) { + return OPCODE_MAP.getOrDefault(opcode, UNKNOWN); + } + + public enum Direction { + CLIENT_TO_SERVER, + SERVER_TO_CLIENT, + BIDIRECTIONAL + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/PacketContext.java b/src/main/java/com/gcemu/gcpp/packets/PacketContext.java new file mode 100644 index 0000000..43e218c --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/PacketContext.java @@ -0,0 +1,82 @@ +package com.gcemu.gcpp.packets; + +import static java.util.Objects.isNull; + +import com.gcemu.gcpp.pcapng.TcpPacketParser; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public final class PacketContext { + private static final int HEADER_SIZE = 7; + + private final byte[] rawPayload; + private final TcpPacketParser.TcpSegment segment; + private final int serverPort; + private final boolean decompressContent; + + private byte[] contentCache; + + public byte[] content() { + if (isNull(contentCache)) { + synchronized (this) { + if (isNull(contentCache)) { + contentCache = buildContent(); + } + } + } + return contentCache; + } + + private byte[] buildContent() { + // Always strip the 7-byte GCNet header + byte[] contentBytes; + if (rawPayload.length <= HEADER_SIZE) { + contentBytes = rawPayload; + } else { + contentBytes = Arrays.copyOfRange(rawPayload, HEADER_SIZE, rawPayload.length); + } + + // If the parser declared it needs decompression and the flag is set, decompress + if (decompressContent + && PayloadContentExtractor.isCompressed(rawPayload) + && contentBytes.length > 4) { + return PayloadContentExtractor.decompress(contentBytes); + } + + return contentBytes; + } + + public boolean isCompressed() { + return PayloadContentExtractor.isCompressed(rawPayload); + } + + public boolean isClientToServer() { + return segment.dstPort() == serverPort; + } + + public boolean isServerToClient() { + return segment.srcPort() == serverPort; + } + + public Opcode getOpcode() { + if (rawPayload.length < 2) { + return Opcode.UNKNOWN; + } + + var opcode = ((rawPayload[0] & 0xFF) << 8) | (rawPayload[1] & 0xFF); + return Opcode.valueOf(opcode); + } + + @Override + public String toString() { + return String.format( + "%s [%d bytes content] %s:%d -> %s:%d", + getOpcode().getName(), + content().length, + segment.srcIp(), + segment.srcPort(), + segment.dstIp(), + segment.dstPort()); + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/PacketParser.java b/src/main/java/com/gcemu/gcpp/packets/PacketParser.java new file mode 100644 index 0000000..58e5cf8 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/PacketParser.java @@ -0,0 +1,59 @@ +package com.gcemu.gcpp.packets; + +import java.util.Collections; +import java.util.Map; + +public interface PacketParser { + ParseResult parse(PacketContext context); + + default String formatContentHex(byte[] data) { + var sb = new StringBuilder(); + for (var i = 0; i < data.length; i++) { + if (i % 16 == 0 && i > 0) { + sb.append("\n"); + } + sb.append(String.format("%02X ", data[i])); + } + return sb.toString().trim(); + } + + default String bytesToHex(byte[] bytes) { + var sb = new StringBuilder(); + for (var b : bytes) { + sb.append(String.format("%02X", b)); + } + return sb.toString(); + } + + record ParseResult( + Opcode opcode, + String opcodeName, + String direction, + String structure, + Map fields, + String rawHex, + String readableText) { + + public static ParseResult empty(Opcode opcode, String direction) { + return new ParseResult( + opcode, opcode.getName(), direction, "", Collections.emptyMap(), "", ""); + } + + public static ParseResult parsed( + Opcode opcode, + String direction, + String structure, + Map fields, + String rawHex, + String readableText) { + return new ParseResult( + opcode, opcode.getName(), direction, structure, fields, rawHex, readableText); + } + } + + @java.lang.annotation.Target(java.lang.annotation.ElementType.TYPE) + @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME) + @interface PacketParserFor { + int opcode(); + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/PacketParserFactory.java b/src/main/java/com/gcemu/gcpp/packets/PacketParserFactory.java new file mode 100644 index 0000000..46ad7ff --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/PacketParserFactory.java @@ -0,0 +1,28 @@ +package com.gcemu.gcpp.packets; + +import com.gcemu.gcpp.packets.parsers.*; +import java.util.HashMap; +import java.util.Map; + +public class PacketParserFactory { + private final Map parsers = new HashMap<>(); + private final PacketParser defaultParser = new GenericPayloadParser(); + + public PacketParserFactory() { + registerBuiltInParsers(); + } + + public PacketParser getParser(int opcode) { + return parsers.getOrDefault(opcode, defaultParser); + } + + public void registerParser(int opcode, PacketParser parser) { + parsers.put(opcode, parser); + } + + private void registerBuiltInParsers() { + registerParser(0, new HeartBeatParser()); + registerParser(1, new KeyExchangeParser()); + registerParser(2, new VerifyAccountReqParser()); + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/PayloadContentExtractor.java b/src/main/java/com/gcemu/gcpp/packets/PayloadContentExtractor.java new file mode 100644 index 0000000..e66343d --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/PayloadContentExtractor.java @@ -0,0 +1,107 @@ +package com.gcemu.gcpp.packets; + +import static java.util.Objects.isNull; +import static java.util.Objects.nonNull; + +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +import java.util.zip.Inflater; + +public final class PayloadContentExtractor { + private static final int HEADER_SIZE = 7; + + /** + * Extracts content bytes from a decrypted GCNet payload. Strips the 7-byte header and + * decompresses if needed. + * + * @param decryptedPayload the full decrypted payload (header + content + padding) + * @return the content bytes (decompressed if compression flag was set) + */ + public static byte[] extract(byte[] decryptedPayload) { + if (isNull(decryptedPayload) || decryptedPayload.length < HEADER_SIZE) { + return new byte[0]; + } + + var compressed = decryptedPayload[6] != 0; + var contentSize = readIntBE(decryptedPayload); + var contentStart = HEADER_SIZE; + + if (contentSize <= 0 || contentStart + contentSize > decryptedPayload.length) { + // Fallback: use all remaining bytes + contentSize = decryptedPayload.length - contentStart; + if (contentSize <= 0) { + return new byte[0]; + } + } + + byte[] rawContent; + if (contentStart + contentSize <= decryptedPayload.length) { + rawContent = Arrays.copyOfRange(decryptedPayload, contentStart, contentStart + contentSize); + } else { + rawContent = Arrays.copyOfRange(decryptedPayload, contentStart, decryptedPayload.length); + } + + if (compressed && rawContent.length > 4) { + return decompress(rawContent); + } + + return rawContent; + } + + public static boolean isCompressed(byte[] decryptedPayload) { + return nonNull(decryptedPayload) && decryptedPayload.length > 6 && decryptedPayload[6] != 0; + } + + static byte[] decompress(byte[] compressedContent) { + // First 4 bytes are the declared decompressed size (little-endian) + var declaredSize = readIntLE(compressedContent); + var compressedData = + java.util.Arrays.copyOfRange(compressedContent, 4, compressedContent.length); + + var inflater = new Inflater(); + inflater.setInput(compressedData); + + var outputStream = new ByteArrayOutputStream(declaredSize > 0 ? declaredSize : 8192); + var buffer = new byte[8192]; + + try { + while (!inflater.finished()) { + var count = inflater.inflate(buffer); + if (count == 0) { + if (inflater.needsInput()) { + break; + } + + if (inflater.needsDictionary()) { + System.err.println("[WARN] Decompression needs dictionary — skipping"); + break; + } + } + + outputStream.write(buffer, 0, count); + } + } catch (Exception e) { + System.err.println("[WARN] Decompression error: " + e.getMessage()); + } finally { + inflater.end(); + } + + return outputStream.toByteArray(); + } + + private static int readIntBE(byte[] data) { + return ((data[2] & 0xFF) << 24) + | ((data[2 + 1] & 0xFF) << 16) + | ((data[2 + 2] & 0xFF) << 8) + | (data[2 + 3] & 0xFF); + } + + private static int readIntLE(byte[] data) { + return (data[0] & 0xFF) + | ((data[1] & 0xFF) << 8) + | ((data[2] & 0xFF) << 16) + | ((data[3] & 0xFF) << 24); + } + + private PayloadContentExtractor() {} +} diff --git a/src/main/java/com/gcemu/gcpp/packets/parsers/GenericPayloadParser.java b/src/main/java/com/gcemu/gcpp/packets/parsers/GenericPayloadParser.java new file mode 100644 index 0000000..286276f --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/parsers/GenericPayloadParser.java @@ -0,0 +1,37 @@ +package com.gcemu.gcpp.packets.parsers; + +import com.gcemu.gcpp.packets.PacketContext; +import com.gcemu.gcpp.packets.PacketParser; +import com.gcemu.gcpp.packets.PacketParserFactory; + +/** + * Generic fallback parser for unrecognized or unimplemented opcodes. + * + *

This parser provides a basic hex dump and string extraction for any packet, even if no + * specific parser is registered for its opcode. It follows the Null Object + * Pattern, providing sensible defaults instead of null or error states. + * + *

When adding support for a new opcode, replace this parser in {@link PacketParserFactory} with + * a specific implementation. + */ +public class GenericPayloadParser implements PacketParser { + @Override + public ParseResult parse(PacketContext context) { + var opcode = context.getOpcode(); + var direction = resolveDirection(context); + + return ParseResult.empty(opcode, direction); + } + + private String resolveDirection(PacketContext context) { + if (context.isClientToServer()) { + return "Client → Server"; + } + + if (context.isServerToClient()) { + return "Server → Client"; + } + + return "Unknown"; + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/parsers/HeartBeatParser.java b/src/main/java/com/gcemu/gcpp/packets/parsers/HeartBeatParser.java new file mode 100644 index 0000000..b1a717e --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/parsers/HeartBeatParser.java @@ -0,0 +1,30 @@ +package com.gcemu.gcpp.packets.parsers; + +import com.gcemu.gcpp.packets.PacketContext; +import com.gcemu.gcpp.packets.PacketParser; + +@PacketParser.PacketParserFor(opcode = 0) +public class HeartBeatParser implements PacketParser { + + @Override + public ParseResult parse(PacketContext context) { + var opcode = context.getOpcode(); + var direction = resolveDirection(context); + + // Heartbeat packets have no meaningful content structure. + // content() is available but we just return an empty result. + return ParseResult.empty(opcode, direction); + } + + private String resolveDirection(PacketContext context) { + if (context.isClientToServer()) { + return "Client -> Server"; + } + + if (context.isServerToClient()) { + return "Server -> Client"; + } + + return "Unknown"; + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/parsers/KeyExchangeParser.java b/src/main/java/com/gcemu/gcpp/packets/parsers/KeyExchangeParser.java new file mode 100644 index 0000000..cfd90ce --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/parsers/KeyExchangeParser.java @@ -0,0 +1,92 @@ +package com.gcemu.gcpp.packets.parsers; + +import com.gcemu.gcpp.packets.Opcode; +import com.gcemu.gcpp.packets.PacketContext; +import com.gcemu.gcpp.packets.PacketParser; +import java.nio.ByteBuffer; +import java.util.LinkedHashMap; +import java.util.Map; + +@PacketParser.PacketParserFor(opcode = 1) +public class KeyExchangeParser implements PacketParser { + + @Override + public ParseResult parse(PacketContext context) { + // content() returns bytes starting at offset 7 of the raw payload + // (the 7-byte GCNet header is already stripped). + var content = context.content(); + var direction = "Server -> Client"; + + // SPI(2) + authKeyLen(4) + authKey(8) + cryptoKeyLen(4) + cryptoKey(8) + seq(4) + lastSeq(4) + + // replayMask(4) = 38 + if (content.length < 38) { + return ParseResult.empty(Opcode.KEY_EXCHANGE, direction); + } + + try { + var parsed = parseContent(content); + return ParseResult.parsed( + Opcode.KEY_EXCHANGE, + direction, + "KEY_EXCHANGE { spi: ushort, auth_key: bytes, crypto_key: bytes, seq_num: uint, last_seq: uint, replay_mask: uint }", + parsed.fields(), + formatContentHex(content), + parsed.readableText()); + } catch (Exception e) { + return ParseResult.empty(Opcode.KEY_EXCHANGE, direction); + } + } + + private record ParsedContent(Map fields, String readableText) {} + + private ParsedContent parseContent(byte[] content) { + Map fields = new LinkedHashMap<>(); + var readable = new StringBuilder(); + var offset = 0; + + // Parse SPI + var bb = ByteBuffer.wrap(content, offset, 2).order(java.nio.ByteOrder.BIG_ENDIAN); + var spi = bb.getShort(); + offset += 2; + fields.put("SPI", String.format("0x%04X", spi)); + + // Parse auth key (with length prefix) + bb = ByteBuffer.wrap(content, offset, 4).order(java.nio.ByteOrder.BIG_ENDIAN); + var authKeyLen = bb.getInt(); + offset += 4; + + if (offset + authKeyLen <= content.length) { + var authKey = new byte[authKeyLen]; + System.arraycopy(content, offset, authKey, 0, authKeyLen); + fields.put("Auth Key", bytesToHex(authKey)); + offset += authKeyLen; + } + + // Parse crypto key (with length prefix) + bb = ByteBuffer.wrap(content, offset, 4).order(java.nio.ByteOrder.BIG_ENDIAN); + var cryptoKeyLen = bb.getInt(); + offset += 4; + + if (offset + cryptoKeyLen <= content.length) { + var cryptoKey = new byte[cryptoKeyLen]; + System.arraycopy(content, offset, cryptoKey, 0, cryptoKeyLen); + fields.put("Crypto Key", bytesToHex(cryptoKey)); + offset += cryptoKeyLen; + } + + // Parse sequence numbers + if (offset + 12 <= content.length) { + bb = ByteBuffer.wrap(content, offset, 12).order(java.nio.ByteOrder.BIG_ENDIAN); + var seqNum = bb.getInt(); + var lastSeq = bb.getInt(); + var replayMask = bb.getInt(); + + fields.put("Seq Num", String.valueOf(seqNum)); + fields.put("Last Seq Num", String.valueOf(lastSeq)); + fields.put("Replay Window Mask", String.format("0x%08X", replayMask)); + } + + readable.append("spi=0x").append(String.format("%04X", spi)); + return new ParsedContent(fields, readable.toString()); + } +} diff --git a/src/main/java/com/gcemu/gcpp/packets/parsers/VerifyAccountReqParser.java b/src/main/java/com/gcemu/gcpp/packets/parsers/VerifyAccountReqParser.java new file mode 100644 index 0000000..111886c --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/packets/parsers/VerifyAccountReqParser.java @@ -0,0 +1,80 @@ +package com.gcemu.gcpp.packets.parsers; + +import com.gcemu.gcpp.packets.CompressedPayloadParser; +import com.gcemu.gcpp.packets.Opcode; +import com.gcemu.gcpp.packets.PacketContext; +import com.gcemu.gcpp.packets.PacketParser; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.LinkedHashMap; +import java.util.Map; + +@PacketParser.PacketParserFor(opcode = 2) +public class VerifyAccountReqParser implements PacketParser, CompressedPayloadParser { + + @Override + public ParseResult parse(PacketContext context) { + var content = context.content(); + var direction = "Client -> Server"; + + try { + var parsed = parseContent(content); + return ParseResult.parsed( + Opcode.VERIFY_ACCOUNT_REQ, + direction, + "VERIFY_ACCOUNT_REQ { login: string, password: string, ip: string, protocol_version: uint }", + parsed.fields(), + formatContentHex(content), + parsed.readableText()); + } catch (Exception e) { + return ParseResult.empty(Opcode.VERIFY_ACCOUNT_REQ, direction); + } + } + + private record ParsedContent(Map fields, String readableText) {} + + private ParsedContent parseContent(byte[] content) { + Map fields = new LinkedHashMap<>(); + var offset = 0; + + // Parse username (with length prefix) + var bb = ByteBuffer.wrap(content, offset, 4).order(ByteOrder.BIG_ENDIAN); + var usernameLength = bb.getInt(); + offset += 4; + var username = new String(content, offset, usernameLength); + fields.put("Username", username); + offset += usernameLength; + + // Parse password (with length prefix) + bb = ByteBuffer.wrap(content, offset, 4).order(java.nio.ByteOrder.BIG_ENDIAN); + var passwordLen = bb.getInt(); + offset += 4; + + if (offset + passwordLen <= content.length) { + var password = new byte[passwordLen]; + System.arraycopy(content, offset, password, 0, passwordLen); + fields.put("Password", bytesToHex(password)); + offset += passwordLen; + } + + // Parse ip (with length prefix) + bb = ByteBuffer.wrap(content, offset, 4).order(java.nio.ByteOrder.BIG_ENDIAN); + var ipLen = bb.getInt(); + offset += 4; + + if (offset + ipLen <= content.length) { + var ip = new byte[ipLen]; + System.arraycopy(content, offset, ip, 0, ipLen); + fields.put("IP", bytesToHex(ip)); + offset += ipLen; + } + + // Parse protocol version + bb = ByteBuffer.wrap(content, offset, 4).order(java.nio.ByteOrder.BIG_ENDIAN); + var protocolVersion = bb.getInt(); + fields.put("Protocol Version", String.valueOf(protocolVersion)); + offset += 4; + + return new ParsedContent(fields, ""); + } +} diff --git a/src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java b/src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java new file mode 100644 index 0000000..72123a8 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java @@ -0,0 +1,32 @@ +package com.gcemu.gcpp.pcapng; + +import fr.bmartel.pcapdecoder.PcapDecoder; +import fr.bmartel.pcapdecoder.structure.types.IPcapngType; +import fr.bmartel.pcapdecoder.structure.types.inter.IEnhancedPacketBLock; +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +public class PcapngParser { + public static final int LINK_TYPE_ETHERNET = 1; + + public List parseFile(File file) throws Exception { + List packets = new ArrayList<>(); + + var decoder = new PcapDecoder(file.getAbsolutePath()); + decoder.decode(); + + var sectionList = decoder.getSectionList(); + + for (IPcapngType block : sectionList) { + if (block instanceof IEnhancedPacketBLock epb) { + var data = epb.getPacketData(); + packets.add(new Packet(data, LINK_TYPE_ETHERNET)); + } + } + + return packets; + } + + public record Packet(byte[] packetData, int linkLayerType) {} +} diff --git a/src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java b/src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java new file mode 100644 index 0000000..4ce2a5f --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java @@ -0,0 +1,172 @@ +package com.gcemu.gcpp.pcapng; + +import static java.util.Objects.isNull; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; + +public class TcpPacketParser { + private static final int ETHERNET_HEADER_SIZE = 14; + private static final short ETHERNET_TYPE_IPV4 = 0x0800; + private static final byte IP_PROTOCOL_TCP = 6; + + public TcpSegment parsePacket(byte[] packetData) { + int ipOffset; + + // Auto-detect packet format by examining the data + // This handles cases where the pcapng library reports wrong link type + ipOffset = detectIpOffset(packetData); + + if (ipOffset < 0) { + return null; // Failed to parse + } + + return parseIPv4AndTCP(packetData, ipOffset); + } + + /** + * Auto-detect where the IP header starts in the packet data. Handles various link layer types and + * raw captures. + */ + private int detectIpOffset(byte[] packetData) { + if (isNull(packetData) || packetData.length < 20) { + return -1; + } + + // Try to detect IPv4 header: first byte should be 0x45 (version 4, IHL 5) + for (var offset = 0; offset <= Math.min(40, packetData.length - 20); offset++) { + var versionAndIhl = packetData[offset]; + var version = (versionAndIhl >> 4) & 0x0F; + var ihl = versionAndIhl & 0x0F; + + if (version == 4 && ihl >= 5) { + // Looks like IPv4 header, verify protocol field is TCP + var headerLength = ihl * 4; + if (offset + headerLength + 13 <= packetData.length) { + var protocol = packetData[offset + 9]; + if (protocol == IP_PROTOCOL_TCP) { + return offset; + } + } + } + } + + // Try standard Ethernet header + if (packetData.length >= ETHERNET_HEADER_SIZE + 20) { + var bb = ByteBuffer.wrap(packetData).order(ByteOrder.BIG_ENDIAN); + bb.position(12); + var etherType = bb.getShort(); + + if (etherType == ETHERNET_TYPE_IPV4) { + return ETHERNET_HEADER_SIZE; + } + } + + // Try Linux SLL header (16 bytes) + if (packetData.length >= 16 + 20) { + var bb = ByteBuffer.wrap(packetData, 14, 2).order(ByteOrder.BIG_ENDIAN); + var protocolType = bb.getShort(); + + if (protocolType == ETHERNET_TYPE_IPV4) { + return 16; + } + } + + return -1; + } + + private TcpSegment parseIPv4AndTCP(byte[] packetData, int ipHeaderStart) { + if (packetData.length < ipHeaderStart + 20) { + System.err.println( + " [DEBUG] IPv4: packet too small for IP header (" + + packetData.length + + " bytes, need " + + (ipHeaderStart + 20) + + ")"); + return null; + } + + var ipBb = + ByteBuffer.wrap(packetData, ipHeaderStart, packetData.length - ipHeaderStart) + .order(ByteOrder.BIG_ENDIAN); + + var versionAndIhl = ipBb.get(); + var ipHeaderLength = (versionAndIhl & 0x0F) * 4; + ipBb.get(); // DSCP/ECN (TOS) + ipBb.getShort(); + ipBb.getShort(); // Identification + ipBb.getShort(); // Flags + Fragment Offset + ipBb.get(); // TTL + var protocol = ipBb.get(); + ipBb.getShort(); // Header checksum + + var srcIp = ipBb.getInt(); + var dstIp = ipBb.getInt(); + + if (protocol != IP_PROTOCOL_TCP) { + return null; // Not TCP + } + + // Parse TCP header + var tcpHeaderStart = ipHeaderStart + ipHeaderLength; + if (packetData.length < tcpHeaderStart + 20) { + return null; + } + + var tcpBb = + ByteBuffer.wrap(packetData, tcpHeaderStart, packetData.length - tcpHeaderStart) + .order(ByteOrder.BIG_ENDIAN); + + var srcPort = tcpBb.getShort() & 0xFFFF; + var dstPort = tcpBb.getShort() & 0xFFFF; + var seqNum = tcpBb.getInt(); + tcpBb.getInt(); + + var dataOffsetAndFlags = tcpBb.get(); + var tcpHeaderLength = ((dataOffsetAndFlags >> 4) & 0x0F) * 4; + + if (tcpHeaderLength < 20 || tcpHeaderStart + tcpHeaderLength > packetData.length) { + return null; // Invalid TCP header + } + + var payloadStart = tcpHeaderStart + tcpHeaderLength; + var payloadLength = packetData.length - payloadStart; + + if (payloadLength <= 0) { + return null; // No payload + } + + var payload = new byte[payloadLength]; + System.arraycopy(packetData, payloadStart, payload, 0, payloadLength); + + return new TcpSegment(intToIp(srcIp), srcPort, intToIp(dstIp), dstPort, seqNum, payload); + } + + public static List filterByPort(List segments, int targetPort) { + List filtered = new ArrayList<>(); + for (var segment : segments) { + if (segment.srcPort() == targetPort || segment.dstPort() == targetPort) { + filtered.add(segment); + } + } + + return filtered; + } + + private String intToIp(int ip) { + return String.format( + "%d.%d.%d.%d", (ip >> 24) & 0xFF, (ip >> 16) & 0xFF, (ip >> 8) & 0xFF, ip & 0xFF); + } + + public record TcpSegment( + String srcIp, int srcPort, String dstIp, int dstPort, int sequenceNumber, byte[] payload) { + @Override + public String toString() { + return String.format( + "%s:%d -> %s:%d [SEQ=%d] %d bytes", + srcIp, srcPort, dstIp, dstPort, sequenceNumber, payload.length); + } + } +} diff --git a/src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java b/src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java new file mode 100644 index 0000000..4dc8c69 --- /dev/null +++ b/src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java @@ -0,0 +1,193 @@ +package com.gcemu.gcpp.security; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.MessageDigest; +import java.util.Arrays; +import javax.crypto.Cipher; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.DESKeySpec; +import javax.crypto.spec.IvParameterSpec; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class SecurityAssociation { + private static final byte[] DEFAULT_CRYPTO_KEY = { + (byte) 0xC7, (byte) 0xD8, (byte) 0xC4, (byte) 0xBF, + (byte) 0xB5, (byte) 0xE9, (byte) 0xC0, (byte) 0xFD + }; + private static final byte[] DEFAULT_AUTH_KEY = { + (byte) 0xC0, (byte) 0xD3, (byte) 0xBD, (byte) 0xC3, + (byte) 0xB7, (byte) 0xCE, (byte) 0xB8, (byte) 0xB8 + }; + + private final byte[] cryptoKey; + private final byte[] authKey; + private final short spi; + + /** Create a security association with default keys (for initial packet). */ + public SecurityAssociation() { + this(DEFAULT_CRYPTO_KEY, DEFAULT_AUTH_KEY, (short) 0); + } + + public DecryptionResult decryptPacket(byte[] secureBuffer) { + return decryptPacket(secureBuffer, 0); + } + + public DecryptionResult decryptPacket(byte[] secureBuffer, int startIndex) { + var length = readShort(secureBuffer, startIndex); + var spiIndex = startIndex + 2; + var ivIndex = startIndex + 8; + var payloadIndex = startIndex + 16; + var icvIndex = startIndex + length - 10; + + var packetSpi = readShort(secureBuffer, spiIndex); + var iv = Arrays.copyOfRange(secureBuffer, ivIndex, ivIndex + 8); + + var payloadLength = length - 16 - 10; // Total - header - ICV + var encryptedPayload = + Arrays.copyOfRange(secureBuffer, payloadIndex, payloadIndex + payloadLength); + + var storedIcv = Arrays.copyOfRange(secureBuffer, icvIndex, icvIndex + 10); + + var authData = Arrays.copyOfRange(secureBuffer, spiIndex, icvIndex); + var icvValid = validateIcv(authData, storedIcv); + + var decryptedPayload = decryptPayload(encryptedPayload, iv); + + return new DecryptionResult(packetSpi, iv, decryptedPayload, icvValid); + } + + private boolean validateIcv(byte[] authData, byte[] storedIcv) { + try { + var calculatedIcv = calculateIcv(authData); + return Arrays.equals(calculatedIcv, storedIcv); + } catch (Exception e) { + System.err.println("ICV validation error: " + e.getMessage()); + return false; + } + } + + private byte[] calculateIcv(byte[] authData) { + try { + var md = MessageDigest.getInstance("MD5"); + + var key = new byte[64]; + System.arraycopy(authKey, 0, key, 0, Math.min(authKey.length, 64)); + + var ipad = new byte[64]; + var opad = new byte[64]; + + for (var i = 0; i < 64; i++) { + ipad[i] = (byte) (key[i] ^ 0x36); + opad[i] = (byte) (key[i] ^ 0x5C); + } + + md.update(ipad); + var innerHash = md.digest(authData); + + var md2 = MessageDigest.getInstance("MD5"); + md2.update(opad); + var fullHmac = md2.digest(innerHash); + + return Arrays.copyOf(fullHmac, 10); + } catch (Exception e) { + throw new RuntimeException("Failed to calculate ICV", e); + } + } + + private byte[] decryptPayload(byte[] encryptedPayload, byte[] iv) { + try { + var keySpec = new DESKeySpec(cryptoKey); + var keyFactory = SecretKeyFactory.getInstance("DES"); + var key = keyFactory.generateSecret(keySpec); + + var cipher = Cipher.getInstance("DES/CBC/NoPadding"); + var ivSpec = new IvParameterSpec(iv); + cipher.init(Cipher.DECRYPT_MODE, key, ivSpec); + + var decrypted = cipher.doFinal(encryptedPayload); + + // Remove padding + var paddingLength = decrypted[decrypted.length - 1] + 1; + + if (paddingLength > 0 && paddingLength <= decrypted.length) { + return Arrays.copyOf(decrypted, decrypted.length - paddingLength); + } + + return decrypted; + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt payload", e); + } + } + + private short readShort(byte[] buffer, int offset) { + // Little-endian + return (short) ((buffer[offset] & 0xFF) | ((buffer[offset + 1] & 0xFF) << 8)); + } + + /** + * Parse the initial key exchange packet (opcode 1) to extract new security parameters. + * + *

Initial packet content structure (big-endian): - New SPI (2 bytes) - Authentication Key (8 + * bytes) - Encryption Key (8 bytes) - Sequence Number (4 bytes) - Last Sequence Number (4 bytes) + * - Replay Window Mask (4 bytes) + */ + public static SecurityAssociation parseInitialKeyExchange(byte[] decryptedPayload) { + try { + // Payload header: opcode (2 bytes), content size (4 bytes), compression flag (1 byte) + int offset = 7; + + // Parse content (big-endian) + var bb = ByteBuffer.wrap(decryptedPayload, offset, decryptedPayload.length - offset); + bb.order(ByteOrder.BIG_ENDIAN); + + var newSpi = bb.getShort(); + + var authKeyLen = bb.getInt(); + var newAuthKey = new byte[authKeyLen]; + bb.get(newAuthKey); + + var cryptoKeyLen = bb.getInt(); + var newCryptoKey = new byte[cryptoKeyLen]; + bb.get(newCryptoKey); + + var seqNum = bb.getInt(); + bb.getInt(); + bb.getInt(); + + System.out.println("Extracted security parameters from key exchange:"); + System.out.printf(" SPI: 0x%04X%n", newSpi); + System.out.printf(" Auth Key (%d bytes): %s%n", authKeyLen, bytesToHex(newAuthKey)); + System.out.printf(" Crypto Key (%d bytes): %s%n", cryptoKeyLen, bytesToHex(newCryptoKey)); + System.out.printf(" Seq Num: %d%n", seqNum); + + return new SecurityAssociation(newCryptoKey, newAuthKey, newSpi); + } catch (Exception e) { + System.err.println("Failed to parse initial key exchange: " + e.getMessage()); + return null; + } + } + + public static boolean isInitialKeyExchange(byte[] decryptedPayload) { + if (decryptedPayload.length < 2) { + return false; + } + + // Big-endian opcode + short opcode = (short) (((decryptedPayload[0] & 0xFF) << 8) | (decryptedPayload[1] & 0xFF)); + return opcode == 1; + } + + private static String bytesToHex(byte[] bytes) { + var sb = new StringBuilder(); + + for (var b : bytes) { + sb.append(String.format("%02X ", b)); + } + + return sb.toString().trim(); + } + + public record DecryptionResult(short spi, byte[] iv, byte[] decryptedPayload, boolean icvValid) {} +}