add initial implementation of GCNet packet decryptor with pcapng support
This commit is contained in:
commit
68b69a8bd1
72
.gitignore
vendored
Normal file
72
.gitignore
vendored
Normal 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
120
README.md
Normal 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
|
||||
274
docs/ADDING_PACKET_PARSERS.md
Normal file
274
docs/ADDING_PACKET_PARSERS.md
Normal 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
95
pom.xml
Normal 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>
|
||||
36
src/main/java/com/gcemu/gcpp/CliArguments.java
Normal file
36
src/main/java/com/gcemu/gcpp/CliArguments.java
Normal 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);
|
||||
}
|
||||
}
|
||||
88
src/main/java/com/gcemu/gcpp/GCPacketParser.java
Normal file
88
src/main/java/com/gcemu/gcpp/GCPacketParser.java
Normal 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();
|
||||
}
|
||||
}
|
||||
151
src/main/java/com/gcemu/gcpp/OutputFormatter.java
Normal file
151
src/main/java/com/gcemu/gcpp/OutputFormatter.java
Normal 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();
|
||||
}
|
||||
}
|
||||
40
src/main/java/com/gcemu/gcpp/PacketExtractor.java
Normal file
40
src/main/java/com/gcemu/gcpp/PacketExtractor.java
Normal 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) {}
|
||||
}
|
||||
94
src/main/java/com/gcemu/gcpp/PacketProcessor.java
Normal file
94
src/main/java/com/gcemu/gcpp/PacketProcessor.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
package com.gcemu.gcpp.packets;
|
||||
|
||||
public interface CompressedPayloadParser {}
|
||||
39
src/main/java/com/gcemu/gcpp/packets/Opcode.java
Normal file
39
src/main/java/com/gcemu/gcpp/packets/Opcode.java
Normal 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
|
||||
}
|
||||
}
|
||||
82
src/main/java/com/gcemu/gcpp/packets/PacketContext.java
Normal file
82
src/main/java/com/gcemu/gcpp/packets/PacketContext.java
Normal 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());
|
||||
}
|
||||
}
|
||||
59
src/main/java/com/gcemu/gcpp/packets/PacketParser.java
Normal file
59
src/main/java/com/gcemu/gcpp/packets/PacketParser.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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() {}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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, "");
|
||||
}
|
||||
}
|
||||
32
src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java
Normal file
32
src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java
Normal 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) {}
|
||||
}
|
||||
172
src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java
Normal file
172
src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
193
src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java
Normal file
193
src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java
Normal 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) {}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user