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 acf58a7085
18 changed files with 1637 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

134
README.md Normal file
View File

@ -0,0 +1,134 @@
# GCNet Packet Decryptor
A Java tool to decrypt and analyze GCNet (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: 8501), and decrypts them using the GCNet security protocol. 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 using DES-CBC
5. Validates packet integrity using MD5-HMAC ICV
6. Decompresses zlib-compressed payloads
7. Displays decrypted packet contents in human-readable format
## Building
```bash
mvn clean package
```
This creates two JAR files in `target/`:
- `gcnet-decryptor-1.0.0.jar` - Standalone JAR (requires dependencies)
- `gcnet-decryptor-1.0.0-jar-with-dependencies.jar` - Fat JAR with all dependencies (recommended)
## Usage
```bash
java -jar target/gcnet-decryptor-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: 8501)
**Examples:**
```bash
# Decrypt packets on default port 8501
java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar capture.pcapng
# Decrypt packets on custom port
java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar capture.pcapng 9001
```
## How It Works
### GCNet Protocol Structure
The GCNet 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)
- **Null 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 GCNet 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/gcnet/decryptor/
├── GCNetDecryptor.java # Main application
├── pcapng/
│ ├── PcapngParser.java # pcapng file parser (wraps pcapngdecoder)
│ └── TcpPacketParser.java # TCP segment extractor
├── security/
│ └── GCNetSecurityAssociation.java # Decryption & ICV validation
└── payload/
└── GCNetPayloadParser.java # Payload parser & decompression
```
## Dependencies
- **[pcapng-decoder](https://github.com/bertrandmartel/pcapng-decoder)** by Bertrand Martel (MIT License) - Pure Java pcapng file parser
## References
Based on the [GCNet](https://github.com/frihet/GCNet) library by Gabriel F. (Frihet Dev), licensed under AGPL-3.0.
Protocol documentation:
- [The Security Layer](../GCNet/docs/en/The%20Security%20Layer.md)
- [The Cryptography](../GCNet/docs/en/The%20Cryptography.md)
- [The Payload Layer](../GCNet/docs/en/The%20Payload%20Layer.md)
- [The Security Protocol Setup](../GCNet/docs/en/The%20Security%20Protocol%20Setup.md)
## License
This project is provided as-is for educational and analysis purposes.

View File

@ -0,0 +1,213 @@
# Adding New Packet Parsers
This guide explains how to implement parsers for new GCNet packet types.
## Architecture Overview
The packet parsing system uses several design patterns:
```
┌─────────────────────────────────────────────────────────────────┐
│ GCNetPacketProcessor │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ PacketParserFactory (Factory) ││
│ │ ┌────────────┐ ┌────────────┐ ┌──────────────────────────┐ ││
│ │ │ PingParser │ │ PongParser │ │ VerifyAccountRequestParser│ ││
│ │ └────────────┘ └────────────┘ └──────────────────────────┘ ││
│ │ Strategy Pattern ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ PacketContext │
│ • decrypted payload bytes │
│ • TCP segment (src/dst IP:port) │
│ • server port for direction detection │
└─────────────────────────────────────────────────────────────────┘
```
### Design Patterns Used
| Pattern | Class | Purpose |
|---------|-------|---------|
| **Strategy** | `PacketParser` interface | Each opcode has its own parsing strategy |
| **Factory** | `PacketParserFactory` | Creates the correct parser for each opcode |
| **Context** | `PacketContext` | Immutable context with payload + connection metadata |
| **Registry** | `GCNetOpcode` enum | Maps opcode numbers to metadata and direction hints |
| **Null Object** | `GenericPayloadParser` | Fallback for unimplemented opcodes |
## Step-by-Step: Adding a New Parser
### 1. Define the Opcode
Add the opcode to `GCNetOpcode.java`:
```java
// In packets.com.gcpp.GCNetOpcode
/**
* Example: Player join request.
* Structure: Player name (string), Character class (int), Level (short)
*/
ENU_PLAYER_JOIN_REQ(50, "ENU_PLAYER_JOIN_REQ",
"Player join request", Direction.CLIENT_TO_SERVER),
```
The `Direction` enum indicates expected traffic flow:
- `CLIENT_TO_SERVER` — Sent from client **to** server port (dstPort == serverPort)
- `SERVER_TO_CLIENT` — Sent from server **from** server port (srcPort == serverPort)
- `BIDIRECTIONAL` — Valid both ways (e.g., PING/PONG)
### 2. Create the Parser
Create a new class in `packets/parsers/`:
```java
package com.gcnet.decryptor.packets.parsers;
import packets.com.gcemu.gcpp.GCNetOpcode;
import packets.com.gcemu.gcpp.PacketContext;
import packets.com.gcemu.gcpp.PacketParser;
import java.nio.ByteBuffer;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Parser for ENU_PLAYER_JOIN_REQ packets (opcode 50).
*
* <h3>Packet Structure:</h3>
* <table border="1">
* <tr><th>Offset</th><th>Size</th><th>Type</th><th>Description</th></tr>
* <tr><td>0</td><td>2</td><td>ushort (BE)</td><td>Opcode (50)</td></tr>
* <tr><td>2</td><td>4</td><td>int (BE)</td><td>Content size</td></tr>
* <tr><td>6</td><td>1</td><td>bool</td><td>Compression flag</td></tr>
* <tr><td>7</td><td>4</td><td>int (LE)</td><td>Player name byte length</td></tr>
* <tr><td>11</td><td>var</td><td>string (UTF-16LE)</td><td>Player name</td></tr>
* <tr><td>var</td><td>4</td><td>int (LE)</td><td>Character class ID</td></tr>
* <tr><td>var</td><td>2</td><td>short (LE)</td><td>Character level</td></tr>
* </table>
*
* <p>Direction: {@link GCNetOpcode.Direction#CLIENT_TO_SERVER}</p>
*/
@PacketParser.PacketParserFor(opcode = 50)
public class PlayerJoinRequestParser implements PacketParser {
private static final int CONTENT_OFFSET = 7;
@Override
public ParseResult parse(PacketContext context) {
byte[] payload = context.decryptedPayload();
if (payload.length < CONTENT_OFFSET + 10) {
return ParseResult.empty(GCNetOpcode.ENU_PLAYER_JOIN_REQ, "Client → Server");
}
int offset = CONTENT_OFFSET;
Map<String, String> fields = new LinkedHashMap<>();
// Parse player name
ByteBuffer bb = ByteBuffer.wrap(payload, offset, 4)
.order(java.nio.ByteOrder.LITTLE_ENDIAN);
int nameLen = bb.getInt();
offset += 4;
if (offset + nameLen <= payload.length) {
String playerName = new String(payload, offset, nameLen, java.nio.charset.StandardCharsets.UTF_16LE);
fields.put("Player Name", playerName);
offset += nameLen;
}
// Parse character class
bb = ByteBuffer.wrap(payload, offset, 4)
.order(java.nio.ByteOrder.LITTLE_ENDIAN);
int charClass = bb.getInt();
fields.put("Character Class", String.valueOf(charClass));
offset += 4;
// Parse level
bb = ByteBuffer.wrap(payload, offset, 2)
.order(java.nio.ByteOrder.LITTLE_ENDIAN);
short level = bb.getShort();
fields.put("Level", String.valueOf(level));
return ParseResult.parsed(
GCNetOpcode.ENU_PLAYER_JOIN_REQ,
"Client → Server",
"ENU_PLAYER_JOIN_REQ { name: string_utf16_le, class_id: int32, level: int16 }",
fields,
formatContentHex(payload),
"player=\"" + fields.getOrDefault("Player Name", "?") + "\""
);
}
private String formatContentHex(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();
}
}
```
### 3. Register the Parser
Add registration in `PacketParserFactory.registerBuiltInParsers()`:
```java
private void registerBuiltInParsers() {
// ... existing registrations ...
// Player Management
registerParser(50, new PlayerJoinRequestParser());
registerParser(51, new PlayerJoinAckParser());
}
```
### 4. Direction Detection
The `PacketContext` automatically determines direction:
```java
public boolean isClientToServer() {
return segment.dstPort() == serverPort; // Destination is the server
}
public boolean isServerToClient() {
return segment.srcPort() == serverPort; // Source is the server
}
```
For a server on port 9501:
- `10.0.1.10:51094 → 10.0.1.10:9501`**Client → Server** (dstPort == 9501)
- `10.0.1.10:9501 → 10.0.1.10:51094`**Server → Client** (srcPort == 9501)
## File Structure
```
src/main/java/com/gcnet/decryptor/packets/
├── GCNetOpcode.java ← Opcode registry (add new opcodes here)
├── PacketContext.java ← Immutable context object
├── PacketParser.java ← Strategy interface + annotation
├── PacketParserFactory.java ← Factory (register new parsers here)
└── parsers/
├── GenericPayloadParser.java ← Fallback for unknown opcodes
├── KeyExchangeParser.java ← Opcode 1
├── PingParser.java ← Opcode 39
├── PongParser.java ← Opcode 40
├── VerifyAccountRequestParser.java ← Opcode 34
└── VerifyAccountAckParser.java ← Opcode 35
```
## Testing
Run the decryptor with your capture file:
```bash
java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar capture.pcapng 9501
```
New parsers will automatically be invoked for their registered opcodes, producing structured field output instead of raw hex dumps.

85
pom.xml Normal file
View File

@ -0,0 +1,85 @@
<?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>
</plugins>
</build>
</project>

View File

@ -0,0 +1,37 @@
package com.gcemu.gcpp;
import lombok.Getter;
import java.io.File;
@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,86 @@
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,148 @@
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,43 @@
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,80 @@
package com.gcemu.gcpp;
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;
import static java.util.Objects.nonNull;
@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;
}
}
// Create context and parse with appropriate parser
var context = new PacketContext(result.decryptedPayload(), segment, serverPort);
var opcode = context.getOpcode();
var parser = parserFactory.getParser(opcode.getOpcode());
var parseResult = parser.parse(context);
return ProcessingResult.success(result, parseResult, newKeyExchangeDetected);
} catch (Exception e) {
return ProcessingResult.error(e.getMessage());
}
}
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,38 @@
package com.gcemu.gcpp.packets;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.HashMap;
import java.util.Map;
@Getter
@RequiredArgsConstructor
public enum Opcode {
KEY_EXCHANGE(1, "KEY_EXCHANGE", "Initial security key exchange", Direction.SERVER_TO_CLIENT),
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,38 @@
package com.gcemu.gcpp.packets;
import com.gcemu.gcpp.pcapng.TcpPacketParser;
public record PacketContext(
byte[] decryptedPayload,
TcpPacketParser.TcpSegment segment,
int serverPort
) {
public boolean isClientToServer() {
return segment.dstPort() == serverPort;
}
public boolean isServerToClient() {
return segment.srcPort() == serverPort;
}
public Opcode getOpcode() {
if (decryptedPayload.length < 2) {
return Opcode.UNKNOWN;
}
var opcode = ((decryptedPayload[0] & 0xFF) << 8) | (decryptedPayload[1] & 0xFF);
return Opcode.valueOf(opcode);
}
@Override
public String toString() {
return String.format("%s [%d bytes] %s:%d -> %s:%d",
getOpcode().getName(),
decryptedPayload.length,
segment.srcIp(), segment.srcPort(),
segment.dstIp(), segment.dstPort()
);
}
}

View File

@ -0,0 +1,46 @@
package com.gcemu.gcpp.packets;
import java.util.Collections;
import java.util.Map;
public interface PacketParser {
ParseResult parse(PacketContext context);
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() {
// Security Protocol
registerParser(1, new KeyExchangeParser());
}
}

View File

@ -0,0 +1,38 @@
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>
*
* <p>When adding support for a new opcode, replace this parser in
* {@link PacketParserFactory} with a specific implementation.</p>
*/
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,141 @@
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;
/**
* Parser for KEY_EXCHANGE packets (opcode 1).
*
* <h3>Packet Structure:</h3>
* <table border="1">
* <tr><th>Offset</th><th>Size</th><th>Type</th><th>Description</th></tr>
* <tr><td>0</td><td>2</td><td>ushort (BE)</td><td>Opcode (1)</td></tr>
* <tr><td>2</td><td>4</td><td>int (BE)</td><td>Content size (38)</td></tr>
* <tr><td>6</td><td>1</td><td>bool</td><td>Compression flag (false)</td></tr>
* <tr><td>7</td><td>2</td><td>ushort (BE)</td><td>New SPI for session</td></tr>
* <tr><td>9</td><td>4</td><td>int (BE)</td><td>Auth key length</td></tr>
* <tr><td>13</td><td>var</td><td>byte[]</td><td>Authentication key</td></tr>
* <tr><td>var</td><td>4</td><td>int (BE)</td><td>Crypto key length</td></tr>
* <tr><td>var</td><td>var</td><td>byte[]</td><td>Encryption key</td></tr>
* <tr><td>var</td><td>4</td><td>int (BE)</td><td>Sequence number (initial)</td></tr>
* <tr><td>var</td><td>4</td><td>int (BE)</td><td>Last sequence number</td></tr>
* <tr><td>var</td><td>4</td><td>int (BE)</td><td>Replay window mask</td></tr>
* </table>
*
* <p>This is the first packet in every session, sent by the server using
* default encryption keys. It establishes the session-specific SPI,
* authentication key, and encryption key for all subsequent packets.</p>
*
* <p>Direction: {@link Opcode.Direction#SERVER_TO_CLIENT}</p>
*/
@PacketParser.PacketParserFor(opcode = 1)
public class KeyExchangeParser implements PacketParser {
private static final int CONTENT_OFFSET = 7;
@Override
public ParseResult parse(PacketContext context) {
var payload = context.decryptedPayload();
var direction = "Server → Client";
if (payload.length < CONTENT_OFFSET + 38) {
return ParseResult.empty(Opcode.KEY_EXCHANGE, direction);
}
try {
var content = parseContent(payload);
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 }",
content.fields(),
formatContentHex(payload),
content.readableText()
);
} catch (Exception e) {
return ParseResult.empty(Opcode.KEY_EXCHANGE, direction);
}
}
private record ParsedContent(Map<String, String> fields, String readableText) {}
private ParsedContent parseContent(byte[] payload) {
Map<String, String> fields = new LinkedHashMap<>();
var readable = new StringBuilder();
var offset = CONTENT_OFFSET;
// Parse SPI
var bb = ByteBuffer.wrap(payload, 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(payload, offset, 4).order(java.nio.ByteOrder.BIG_ENDIAN);
var authKeyLen = bb.getInt();
offset += 4;
if (offset + authKeyLen <= payload.length) {
var authKey = new byte[authKeyLen];
System.arraycopy(payload, offset, authKey, 0, authKeyLen);
fields.put("Auth Key", bytesToHex(authKey));
offset += authKeyLen;
}
// Parse crypto key (with length prefix)
bb = ByteBuffer.wrap(payload, offset, 4).order(java.nio.ByteOrder.BIG_ENDIAN);
var cryptoKeyLen = bb.getInt();
offset += 4;
if (offset + cryptoKeyLen <= payload.length) {
var cryptoKey = new byte[cryptoKeyLen];
System.arraycopy(payload, offset, cryptoKey, 0, cryptoKeyLen);
fields.put("Crypto Key", bytesToHex(cryptoKey));
offset += cryptoKeyLen;
}
// Parse sequence numbers
if (offset + 12 <= payload.length) {
bb = ByteBuffer.wrap(payload, 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());
}
private String bytesToHex(byte[] bytes) {
var sb = new StringBuilder();
for (var b : bytes) {
sb.append(String.format("%02X", b));
}
return sb.toString();
}
private 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();
}
}

View File

@ -0,0 +1,37 @@
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,173 @@
package com.gcemu.gcpp.pcapng;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.List;
import static java.util.Objects.isNull;
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,200 @@
package com.gcemu.gcpp.security;
import lombok.RequiredArgsConstructor;
import javax.crypto.Cipher;
import javax.crypto.spec.DESKeySpec;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.SecretKeyFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.security.MessageDigest;
import java.util.Arrays;
@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) {
}
}