add initial implementation of GCNet packet decryptor with pcapng support

This commit is contained in:
Rodrigo Verdiani 2026-04-08 16:00:56 -03:00
commit 68b69a8bd1
22 changed files with 1924 additions and 0 deletions

72
.gitignore vendored Normal file
View File

@ -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

120
README.md Normal file
View File

@ -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 <pcapng-file> [port]
```
**Parameters:**
- `<pcapng-file>`: 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

View File

@ -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).
*
* <h3>Content Structure (offset 0, header already stripped):</h3>
* <table border="1">
* <tr><th>Offset</th><th>Size</th><th>Type</th><th>Description</th></tr>
* <tr><td>0</td><td>4</td><td>int (LE)</td><td>Username byte length</td></tr>
* <tr><td>4</td><td>var</td><td>string (UTF-16LE)</td><td>Username</td></tr>
* <tr><td>var</td><td>4</td><td>int (LE)</td><td>Character class</td></tr>
* </table>
*/
@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<String, String> 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).
*
* <p>By implementing {@link CompressedPayloadParser}, the framework
* decompresses the content before {@code parse()} is called.</p>
*/
@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<String, String> 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
```

95
pom.xml Normal file
View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.gcemu</groupId>
<artifactId>gcpp</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.44</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>fr.bmartel</groupId>
<artifactId>pcapngdecoder</artifactId>
<version>1.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.44</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.gcemu.gcpp.GCPacketParser</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.gcemu.gcpp.GCPacketParser</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<java>
<googleJavaFormat/>
</java>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -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 <pcapng-file> [port]");
System.out.println(" <pcapng-file>: Path to the pcapng capture file");
System.out.println(" [port]: TCP port to filter on (default: " + DEFAULT_PORT + ")");
System.exit(1);
}
}

View File

@ -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<TcpPacketParser.TcpSegment> 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();
}
}

View File

@ -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();
}
}

View File

@ -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<TcpPacketParser.TcpSegment> 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<TcpPacketParser.TcpSegment> filteredSegments) {}
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,3 @@
package com.gcemu.gcpp.packets;
public interface CompressedPayloadParser {}

View File

@ -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<Integer, Opcode> 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
}
}

View File

@ -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());
}
}

View File

@ -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<String, String> fields,
String rawHex,
String readableText) {
public static ParseResult empty(Opcode opcode, String direction) {
return new ParseResult(
opcode, opcode.getName(), direction, "<unparsed>", Collections.emptyMap(), "", "");
}
public static ParseResult parsed(
Opcode opcode,
String direction,
String structure,
Map<String, String> 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();
}
}

View File

@ -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<Integer, PacketParser> 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());
}
}

View File

@ -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() {}
}

View File

@ -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.
*
* <p>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 <strong>Null Object
* Pattern</strong>, providing sensible defaults instead of null or error states.
*
* <p>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";
}
}

View File

@ -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";
}
}

View File

@ -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<String, String> fields, String readableText) {}
private ParsedContent parseContent(byte[] content) {
Map<String, String> 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());
}
}

View File

@ -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<String, String> fields, String readableText) {}
private ParsedContent parseContent(byte[] content) {
Map<String, String> 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, "");
}
}

View File

@ -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<Packet> parseFile(File file) throws Exception {
List<Packet> 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) {}
}

View File

@ -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<TcpSegment> filterByPort(List<TcpSegment> segments, int targetPort) {
List<TcpSegment> 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);
}
}
}

View File

@ -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.
*
* <p>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) {}
}