add initial implementation of GCNet packet decryptor with pcapng support
This commit is contained in:
commit
acf58a7085
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
|
||||
134
README.md
Normal file
134
README.md
Normal 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.
|
||||
213
docs/ADDING_PACKET_PARSERS.md
Normal file
213
docs/ADDING_PACKET_PARSERS.md
Normal 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
85
pom.xml
Normal 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>
|
||||
37
src/main/java/com/gcemu/gcpp/CliArguments.java
Normal file
37
src/main/java/com/gcemu/gcpp/CliArguments.java
Normal 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);
|
||||
}
|
||||
}
|
||||
86
src/main/java/com/gcemu/gcpp/GCPacketParser.java
Normal file
86
src/main/java/com/gcemu/gcpp/GCPacketParser.java
Normal 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();
|
||||
}
|
||||
}
|
||||
148
src/main/java/com/gcemu/gcpp/OutputFormatter.java
Normal file
148
src/main/java/com/gcemu/gcpp/OutputFormatter.java
Normal 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();
|
||||
}
|
||||
}
|
||||
43
src/main/java/com/gcemu/gcpp/PacketExtractor.java
Normal file
43
src/main/java/com/gcemu/gcpp/PacketExtractor.java
Normal 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) {
|
||||
}
|
||||
}
|
||||
80
src/main/java/com/gcemu/gcpp/PacketProcessor.java
Normal file
80
src/main/java/com/gcemu/gcpp/PacketProcessor.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/main/java/com/gcemu/gcpp/packets/Opcode.java
Normal file
38
src/main/java/com/gcemu/gcpp/packets/Opcode.java
Normal 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
|
||||
}
|
||||
}
|
||||
38
src/main/java/com/gcemu/gcpp/packets/PacketContext.java
Normal file
38
src/main/java/com/gcemu/gcpp/packets/PacketContext.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/main/java/com/gcemu/gcpp/packets/PacketParser.java
Normal file
46
src/main/java/com/gcemu/gcpp/packets/PacketParser.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
37
src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java
Normal file
37
src/main/java/com/gcemu/gcpp/pcapng/PcapngParser.java
Normal 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) {
|
||||
}
|
||||
}
|
||||
173
src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java
Normal file
173
src/main/java/com/gcemu/gcpp/pcapng/TcpPacketParser.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
200
src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java
Normal file
200
src/main/java/com/gcemu/gcpp/security/SecurityAssociation.java
Normal 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) {
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user