Skip to content

Instantly share code, notes, and snippets.

@adamnew123456
Created November 17, 2024 17:03
Show Gist options
  • Save adamnew123456/95599bf69e6c30f641b4729c218bdf59 to your computer and use it in GitHub Desktop.
Save adamnew123456/95599bf69e6c30f641b4729c218bdf59 to your computer and use it in GitHub Desktop.
Command and Conquer 3 - LAN Protocol Exploration and Tooling

LAN Discovery Protocol

Basics

LAN discovery works by broadcasting encrypted UDP packets to the address 255.255.255.255, from port 8086-8093 (inclusive). Every LAN discovery message is sent across each of these ports with the same content.

The game does this as a fallback, in case it can't bind its preferred discovery port for whatever reason. It prefers to listen for broadcasts on port 8086 but will bind one of the other ports if it can't bind 8086.

All LAN discovery messages have a fixed size of 476 bytes. The content of the messages is usually smaller, but the game allocates a fixed size buffer for LAN discovery and reuses it every time. The packet encryption scheme (covered below) works on the entire packet, including garbage data, so keep that in mind if you want to send messages to the game.

Packet Encryption

LAN discovery messages go through a process like this:

  1. The packet's message is encoded into the packet buffer, starting at byte 4 and using as much of the packet as necessary.

  2. A hash of the packet is calculated and written to the first four bytes. The first four bytes are skipped when calculating the hash, but everything else (including the unused portion of the buffer after the message) is included.

  3. The packet is encrypted. This includes the whole buffer, with the hash and any unused bytes.

public static uint HashPacket(ReadOnlySpan<byte> packet)
{
    var hashval = 0u;
    foreach (var b in packet)
        hashval = (b + (hashval * 2)) + (hashval >> 31);
    return hashval;
}

const uint KeyIV = 0x38d9b7d4;
const uint KeyIncr = 0x80c63af2;

// NOTE: The LittleEndian parts assume you are running on a little-endian
// processor. The C# equivalents of ntohl / htonl do not accept slices.

private static void EncryptPacket(Span<byte> packet)
{
    Debug.Assert(packet.Length % 4 == 0, "Packet length not a multiple of 4");

    var key = KeyIV;
    for (var i = 0; i < packet.Length; i += 4)
    {
        var currentInt = packet.Slice(i);
        var value = BinaryPrimitives.ReadUInt32LittleEndian(currentInt);
        value ^= key;
        BinaryPrimitives.WriteUInt32BigEndian(currentInt, value);
        key += KeyIncr;
    }
}

private static void DecryptPacket(Span<byte> packet)
{
    Debug.Assert(packet.Length % 4 == 0, "Packet length not a multiple of 4");

    var key = KeyIV;
    for (var i = 0; i < packet.Length; i += 4)
    {
        var currentInt = packet.Slice(i);
        var value = BinaryPrimitives.ReadUInt32BigEndian(currentInt);
        value ^= key;
        BinaryPrimitives.WriteUInt32LittleEndian(currentInt, value);
        key += KeyIncr;
    }
}

You can validate these yourself by running a copy of cnc3game.dat (not the EXE, as that is only a launcher) through Ghidra. Look for the KeyIV and KeyIncr constants mentioned in the code - the only two mentions I found are in the packet encrypt routine at 0x661073 and the packet decrypt routine at 0x665ac3.

(If you want to do this the hard way, start from sendto and work your way back up. You'll need the symbol ordinals for WinSock2 to make any sense of the external function references in the Import section)

Packet Layout

At this point you can decode and validate the messages coming off the wire. Interpreting them is a bit more complicated and I have only a partial understanding of how this works.

If you're following along in Ghidra, take a look at the LANAPI class that the C++ analyzer found. Its vtable should be at address 0xa84dc4. The methods here contain the logic for generating and parsing the packet contents.

Shared Header

All packets share a common 34 byte header. I'm using C type names to describe these, so an int is 32 bits and a short is 16 bits. All integers are big-endian, and all characters and strings are little-endian UTF-16.

  • packet[0:4] Int - The packet hash.
  • packet[4:8] Int - The command. Note that these are fit within 1 byte, so you can think of this as being a byte followed by 3 bytes of zeroes.
  • packet[8:30] String - The player's nickname. Note that this is NUL-terminated, so even though you can fit 11 Unicode characters (22 bytes), you can only use the first 10 of them.
  • packet[30:32] Char - The first character returned by the function GetUserNameA.
  • packet[32:34] Char - The first character returned by the function GetComputerNameA.

(If you're following along at home, look at the vtable entry at 0xa84ea4)

The last two entries are oddballs. The game doesn't validate these as far as I can tell, you can put whatever characters you want in these if you are crafting your own messages. I can only assume EA's developers found this useful for identifying packets when debugging? Hopefully there aren't two Daves in the office!

Message Types

If you track down the references to the function that generates the packet header, or you find where the packet command is dispatched (vtable 0xa84dd8), you'll see that there are 20 separate commands with IDs from 0x00 to 0x13.

I know the layouts of a handful of these. Enough to detect players on the LAN myself and send chat messages into the global lobby and individual game lobbies.

IDLE Messages

IDLE messages use the command code 0x02. They contain only the header and no other data. Sending this causes your nickname to appear in the player list of anyone on the same network.

CHAT Messages

CHAT messages use the command code 0x0b. They look like this:

  • packet[34:68] String - The session of the ID that the chat was sent to. NUL-terminated like nickname. Each game lobby has a 16-character session ID, which is upper-case hex consisting of the host's IP address (C0A801C8 being 192.168.1.200) and an additional 4 bytes whose purpose I don't know. The entire session ID may be NUL bytes which indicates a chat sent to the global lobby screen instead of a specific game lobby.

  • packet[68:72] Int - The type of chat message. As with the command there are only a few types of these, so usually on the first byte is set. I know of three: 0x00 "player" chats sent within the main chat window, 0x01 "comrade" chats sent from the chat window (the mail icon in the top right), and 0x03 "rule" chats sent by the system for things like rule changes and announcing the match start countdown.

  • packet[72:274] String - The chat message, 100 Unicode characters followed by a NUL terminator. 100 characters is a guess based on string copy calls in the decompiled code.

CONFIG Messages

CONFIG messages use command code 0x10. Their only content, besides the header, is a NUL-terminated Unicode string. I don't know what the max length on these is.

They are used for transmitting changes that players make to their own game settings, such as their chosen Color or Faction. They also contain some system properties that are unused as far as I know (at least in LAN - some look related to GameSpy). Most are Key=Value like User=Frank, Host=DESKTOP-ABCD0123, PlayerTemplate=8, or Color=0.

HOST / ADVERTISE Messages

HOST (0x01) and ADVERTISE (0x13) are both broadcast by a host to tell other players about their game lobby. This is the most complicated packet format and is the one where Tiberium Wars and Kane's Wrath show differences.

HOST contains all the data that ADVERTISE does plus some more, so I'll be marking HOST-only parts of the packet. Similarly with the place where TW vs. KW makes a difference. Offsets are kind of strange in this format - the parsing code contains lots of references to strcpy and strdup which makes me think some of this data is variable length. Because of this I won't list offsets except for the first few fields.

  • packet[34:68] String - Session ID, as in CHAT messages. Not present in ADVERTISE messages.
  • packet[68:70] Unknown. Always zero from what I've seen.
  • Packed data
    • The map name. This is a NUL-terminated ASCII string. It contains a leading m that's not actually part of the map identifier (mmap_mp_2_chuck1 when the asset files call it map_mp_2_chuck1). I have no idea why. I also have no idea why this is ASCII when every other string is Unicode, even the session ID which only contains ASCII characters.
    • 1 short, followed by four ints. Purpose unknown.
    • The match speed encoded as an int. Goes from 0 to 100.
    • The number of starting resources encoded as an int. Starts at 10k, going up by 5k increments.
    • 4 ints. Purpose unknown, though I assume they are some kind of match config parameter.
    • Whether random crates are enabled, encoded as an int. Either 1 or 0.
    • 5 ints (in Tiberium Wars), or 7 ints (in Kane's Wrath). Several of these are probably signed -1 values (0xffffffff) but I don't know what they are.
    • Tiberum Wars only:
      • An int.
      • The ASCII string "CNC3" followed by a NUL terminator.
      • A short.
    • Kane's Wrath only:
      • 2 ints followed by a byte.

The remainder of the packet is what 8 of what I'm calling a "player description" blocks. These start with a single ASCII character giving the player type, followed by different data for each player type:

  • P A human player.
    • The nickname, same format as in the header.
    • The player's IP address as a big-endian int.
    • The player's port number. Not the port used for game data, just the port used for LAN broadcasting. Usually 8086.
    • 6 bytes. These correspond to things like the player's faction, team, color, and handicap but I haven't mapped each individual byte to a specific function.
  • E / M / H / B A bot, with the letter signifying difficulty (easy/medium/hard/brutal).
    • Contains the same final 6 bytes as the player.
  • O An open slot. Contains no data.
  • C A closed slot. Contains no data.
  • X An error slot? Contains no data. Only mentioned in the decompiled packet parser (code 0x66dde2), I've never seen this live.

Other Stuff

This Seems Really Incomplete?

Yeah :/ The story here is a tragedy, starring myself, Ghidra, and libpcap.

The original goal here was to write a LAN game relay. I self-host Wireguard and really like it, at least compared to my old OpenVPN setup or figuring out which of a jillion VPN providers is trustworthy and Linux-compatible.

(There is also CNC Online, but the Linux compatibility story there is sketchy, it amounts to "apply a pile of tweaks to Proton 7" and "ask some dude for a special version of the installer". I'll pass. As much work as hacking out my own solution is, I at least learn something along the way and get an end result that I understand.)

The problem is that Wireguard is a layer 3 VPN, which is OK for direct game-to-game communication (or more traditional game servers), but it doesn't support UDP broadcast which is necessary to play CNC3.

The plan was to write a small relay that would run locally, alongside the game, intercept the LAN broadcast messages, and then forward them to other copies of the program run by all players. Then their relay would send a broadcast packet exactly matching (all of it, including Ethernet and IP headers) what the other relay received. Thus faking broadcast over a medium that doesn't support it.

I got the monitoring part working, but the packet injection story fell apart for two reasons:

  • On Linux, pcap can only write packets to the interface for sending. This is a huge problem because it means the only way for CNC3 to see the packets is for the router to mirror them back. This didn't work for me, so it is at best fragile. (This is over ethernet - I couldn't get packet injection to work at all on the wireless interface).

  • On Windows, npcap died the moment I tried to inject packets onto the wireless network. I don't have the Windows development chops to explain why.

The next best option I know of involves mucking around with TAP devices, which (a) is basically half-assing a layer 2 VPN (a worse version of OpenVPN bridge mode), and (b) is something of a dark art on Windows. At least AIUI, basically everywhere I read about installing the TAP driver amounted to "install OpenVPN" and code samples were hard to come by.

Socket Affinity

At this point you may decide that you want to send your own messages. Be aware that the game tracks the other games on the local network using the source IP address and port number from the packet.

Meaning, if you want the game to see you as a single client, you need to reuse the same socket for every message. Some message types like chats are dropped if they are not associated with a known client (first introduced by an IDLE message).

Note also that known clients expire on a timer. So if you stop regularly sending out ping messages, other clients on the network will start ignoring you a few seconds later.

The Marked of Kane?

You'll notice that there is no game version in any of the above headers. The game can only detect your mod (Tiberium Wars or Kane's Wrath) using some messages and not others! This leads to the situation where TW and KW players can sit on the same LAN and chat with each other, but cannot join each others games.

I don't know how this works. My decoder guesses by looking at a specific offset within game advertisement messages, since Tiberium Wars puts the string CNC3 there but Kane's Wrath puts other data there. This is probably not how the game itself does it, but then again not much is different in the packet structure for game advertisement messages, so maybe they do...?

Tooling

sender.py should Just Work. Run it with th game running on the LAN lobby screen.

packet-crypto.py is the decoder. If you have a PCAP containing some CNC3 network traffic, export all the packets that match the filter ip.addr == 255.255.255.255 && udp.src_port == 8086 into JSON format.

# Copyright 2024 Chris Marchetti <[email protected]>
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from dataclasses import dataclass
import json
import struct
import sys
PACKET_KEY_INIT = 0x38d9b7d4
PACKET_KEY_INCR = 0x80c63af2
##### DISSECTION #####
@dataclass
class Des:
size: int
value: object
def marker_des(constant):
def _des(buf, offset):
return Des(0, constant)
return _des
def ip_des(buf, offset):
return Des(4, struct.unpack_from('BBBB', buf, offset))
def u32be_des(buf, offset):
return Des(4, struct.unpack_from('>I', buf, offset)[0])
def u16be_des(buf, offset):
return Des(2, struct.unpack_from('>H', buf, offset)[0])
def u16le_des(buf, offset):
return Des(2, struct.unpack_from('<H', buf, offset)[0])
def u8_des(buf, offset):
return Des(1, struct.unpack_from('B', buf, offset)[0])
def utf16_des(length_codepoints, stop_at_nul=False):
if length_codepoints is None and not stop_at_nul:
raise Exception('UTF16 deserializer must have length if not stopping on NUL')
def _des(buf, offset):
buffer = []
consumed = 0
length_chars = length_codepoints * 2 if length_codepoints is not None else None
while length_chars is None or consumed < length_chars:
codepoint = u16le_des(buf, offset + consumed)
consumed += codepoint.size
if codepoint.value == 0:
break
else:
buffer.append(chr(codepoint.value))
if not stop_at_nul:
consumed = length_chars
return Des(consumed, ''.join(buffer))
return _des
def ascii_des(length, stop_at_nul=False):
if length is None and not stop_at_nul:
raise Exception('ASCII deserializer must have length if not stopping on NUL')
def _des(buf, offset):
buffer = []
consumed = 0
while length is None or consumed < length:
char = buf[offset + consumed]
consumed += 1
if char == 0:
break
else:
buffer.append(chr(char))
if not stop_at_nul:
consumed = length
return Des(consumed, ''.join(buffer))
return _des
COMMAND_NAMES = {
0x00: 'EMPTY',
0x01: 'HOST',
0x02: 'IDLE',
0x03: 'GAMEINFO',
0x0b: 'CHAT',
0x10: 'CONFIG',
0x13: 'ADVERTISE',
}
CHAT_TYPES = {
0x03: 'RULE',
0x01: 'COMRADE',
0x00: 'PLAYER',
}
def display_hex32(value: int):
return f'{value:08x}h ({value})'
def display_hex16(value: int):
return f'{value:04x}h ({value})'
def display_hex8(value: int):
return f'{value:02x}h ({value})'
def display_command_type(command: int):
return COMMAND_NAMES.get(command, f'UNK {command:02x}')
def display_chat_types(command: int):
return CHAT_TYPES.get(command, f'UNK {command:02x}')
@dataclass
class Field:
name: str
des: 'Deserializer'
display: 'Display' = None
result: Des = None
def decode(self, buffer: bytearray, offset: int):
self.result = self.des(buffer, offset)
return self.result
@dataclass
class Padding:
size: int
def decode(buf: bytearray):
command = buf[4]
i = 0
while i < len(buf):
remaining = len(buf) - i
if remaining >= 4 and buf[i] == 0xc0 and buf[i + 1] == 0xa8:
print(f'LIKELY IP [net] @ {i:04x}: {buf[i]}.{buf[i + 1]}.{buf[i + 2]}.{buf[i + 3]}')
if i >= 3 and buf[i] == 0xc0 and buf[i - 1] == 0xa8:
print(f'LIKELY IP [host] @ {i:04x}: {buf[i]}.{buf[i - 1]}.{buf[i - 2]}.{buf[i - 3]}')
if remaining >= 2 and buf[i] == 0x1f:
pval = (buf[i] << 8) | buf[i + 1]
if 8000 <= pval <= 9000:
print(f'LIKELY PORT [net] @ {i:04x}: {pval}')
if i >= 1 and buf[i] == 0x1f:
pval = (buf[i] << 8) | buf[i - 1]
if 8000 <= pval <= 9000:
print(f'LIKELY PORT [host] @ {i:04x}: {pval}')
i += 1
# Shared header
yield Field('hdr.hash', u32be_des, display_hex32)
command = Field('hdr.command', u8_des, display_command_type)
yield command
command_val = command.result.value
yield Padding(3)
yield Field('hdr.nickname', utf16_des(11)) # 10 + NUL
yield Field('hdr.first_username_char', ascii_des(1))
yield Padding(1)
yield Field('hdr.first_hostname_char', ascii_des(1))
yield Padding(1)
if command_val == 0x10:
# FIXME: Not sure exactly how big this is, it takes up basically
# the rest of the packet
yield Field('config.text', ascii_des(197))
elif command_val == 0x01 or command_val == 0x13:
if command_val == 0x13:
mod_id = buf[0x79:0x7d]
else:
mod_id = buf[0x9d:0xa1]
kane = mod_id != b'CNC3'
if command_val == 0x1:
yield Field('host.session', utf16_des(16))
yield Padding(4)
yield Field('host.mod', marker_des("Kane's Wrath" if kane else "Tiberium Wars"))
yield Field('host.map', ascii_des(None, stop_at_nul=True))
yield Field('host.0(short)', u16be_des, display_hex16)
yield Field('host.1(int)', u32be_des, display_hex32)
yield Field('host.2(int)', u32be_des, display_hex32)
yield Field('host.3(int)', u32be_des, display_hex32)
yield Field(f'host.4_0(int)[0]', u32be_des, display_hex32)
yield Field(f'host.params.speed', u32be_des, display_hex32)
yield Field(f'host.params.resources', u32be_des, display_hex32)
for x in range(4):
yield Field(f'host.4_1(int)[{x}]', u32be_des, display_hex32)
yield Field(f'host.params.random_crates', u32be_des, display_hex32)
if kane:
for x in range(7): # KW (15 total)
yield Field(f'host.4_2(int)[{x}]', u32be_des, display_hex32)
else:
for x in range(5): # TW (13 total)
yield Field(f'host.4_2(int)[{x}]', u32be_des, display_hex32)
if kane:
yield Field('host.5(int)', u32be_des, display_hex32)
yield Field('host.6(int)', u32be_des, display_hex32)
yield Field('host.6kw(byte)', u8_des, display_hex8)
else:
yield Field('host.5(int)', u32be_des, display_hex32)
yield Field('host.game_id', ascii_des(5))
yield Field('host.7(short)', u16be_des, display_hex16)
for x in range(8):
prefix = f'host.player[{x}]'
player_type = Field(f'{prefix}.type', ascii_des(1))
yield player_type
player_val = player_type.result.value
if player_val == 'P':
yield Field(f'{prefix}.nickname', utf16_des(11))
yield Field(f'{prefix}.host', ip_des)
yield Field(f'{prefix}.port', u16be_des, display_hex16)
yield Field(f'{prefix}.2(byte)', u8_des, display_hex8)
yield Field(f'{prefix}.3(byte)', u8_des, display_hex8)
yield Field(f'{prefix}.4(byte)', u8_des, display_hex8)
yield Field(f'{prefix}.5(byte)', u8_des, display_hex8)
yield Field(f'{prefix}.6(byte)', u8_des, display_hex8)
yield Field(f'{prefix}.7(byte)', u8_des, display_hex8)
elif player_val in 'EMHB':
yield Field(f'{prefix}.0(byte)', u8_des, display_hex8)
yield Field(f'{prefix}.1(byte)', u8_des, display_hex8)
yield Field(f'{prefix}.2(byte)', u8_des, display_hex8)
yield Field(f'{prefix}.3(byte)', u8_des, display_hex8)
yield Field(f'{prefix}.4(byte)', u8_des, display_hex8)
yield Field(f'{prefix}.5(byte)', u8_des, display_hex8)
elif command_val == 0x0b:
yield Field('chat.session', utf16_des(16))
yield Padding(2)
yield Field('chat.type', u8_des, display_chat_types)
yield Padding(3)
yield Field('chat.message', utf16_des(101)) # 100 + NUL
elif command_val == 0x03:
yield Field('unk.ip', ip_des)
yield Padding(2)
yield Field('chat.type', u8_des, display_chat_types)
yield Padding(3)
yield Field('chat.message', utf16_des(101)) # 100 + NUL
def hexdump(buf: bytearray):
offset = 0
char_line = []
print('offset 00 11 22 33 44 55 66 77 88 99 aa bb cc dd ee ff (data)')
print('---------------------------------------------------------')
for b in buf:
if offset == 0:
print(f'{offset:06x}', end='')
elif offset % 16 == 0:
print(f' |{"".join(char_line)}')
print(f'{offset:06x}', end='')
char_line.clear()
char_line.append('.' if b < 0x20 or b >= 0x7f else chr(b))
print(f' {b:02x}', end='')
offset += 1
if char_line:
padding = (16 - (offset % 16)) * 3
print(f'{" " * padding} |{"".join(char_line)}')
def dissect(buf: bytearray):
offset = 0
for field in decode(buf):
if isinstance(field, Padding):
offset += field.size
#print(f'(Padding) -> {offset}')
else: # Field
des = field.decode(buf, offset)
if field.display is not None:
display = field.display(des.value)
else:
display = repr(des.value)
#print(f'{des} -> {offset}')
print(f'{field.name}: {display}')
offset += des.size
##### CRYPTO #####
def i32(value):
return value & 0xff_ff_ff_ff
def hash_packet(buf: bytearray, hashval=0):
for b in buf:
hashval = i32(i32(b + i32(hashval * 2)) + (hashval >> 31))
return hashval
def decrypt_packet(buf: bytearray):
key = PACKET_KEY_INIT
for word_idx in range(len(buf) // 4):
idx = word_idx * 4
word = u32be_des(buf, idx).value
word = word ^ key
struct.pack_into('@I', buf, idx, word)
key = i32(key + PACKET_KEY_INCR)
def main(f1):
if f1 == '-':
capture = json.load(sys.stdin)
else:
with open(f1) as f:
capture = json.load(f)
frames = [
bytearray([
int(b, 16)
for b in c['_source']['layers']['udp']['udp.payload'].split(':')
])
for c in capture
]
decoded = 0
for f in frames:
decrypt_packet(f)
real_hash = struct.unpack_from('@I', f)[0]
expect_hash = hash_packet(f[4:])
print(f'# Hash: {real_hash:x} (expect {expect_hash:x})')
if real_hash == expect_hash:
print('# Data')
hexdump(f)
dissect(f)
print()
decoded += 1
print(f'# Statistics (total={len(frames)} decoded={decoded})')
if __name__ == '__main__':
try:
main(sys.argv[1])
except IndexError:
print(f'Usage: {sys.argv[0]} JSON-FILE')
# Copyright 2024 Chris Marchetti <[email protected]>
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import socket
import struct
import sys
import time
PACKET_KEY_INIT = 0x38d9b7d4
PACKET_KEY_INCR = 0x80c63af2
def hexdump(buf: bytearray):
offset = 0
char_line = []
print('offset 00 11 22 33 44 55 66 77 88 99 aa bb cc dd ee ff (data)')
print('---------------------------------------------------------')
for b in buf:
if offset == 0:
print(f'{offset:06x}', end='')
elif offset % 16 == 0:
print(f' |{"".join(char_line)}')
print(f'{offset:06x}', end='')
char_line.clear()
char_line.append('.' if b < 0x20 or b >= 0x7f else chr(b))
print(f' {b:02x}', end='')
offset += 1
if char_line:
padding = (16 - (offset % 16)) * 3
print(f'{" " * padding} |{"".join(char_line)}')
def i32(value):
return value & 0xff_ff_ff_ff
def hash_packet(buf: bytearray, hashval=0):
for b in buf:
hashval = i32(i32(b + i32(hashval * 2)) + (hashval >> 31))
return hashval
def encrypt_packet(buf: bytearray):
key = PACKET_KEY_INIT
for word_idx in range(len(buf) // 4):
idx = word_idx * 4
word = struct.unpack_from('@I', buf, idx)[0]
word = word ^ key
struct.pack_into('>I', buf, idx, word)
key = i32(key + PACKET_KEY_INCR)
def make_ping_message(nickname: str):
nickname_bytes = nickname.encode('utf-16le').ljust(22, b'\x00')
buffer = bytearray(476)
# hash: 4 bytes (0-4)
buffer[4] = 0x02 # (4-5)
# padding: 3 bytes (5-8)
buffer[8:8+len(nickname_bytes)] = nickname_bytes
struct.pack_into('@I', buffer, 0, hash_packet(buffer[4:]))
return buffer
def make_chat_message(nickname: str, message: str):
nickname_bytes = nickname.encode('utf-16le').ljust(22, b'\x00')
message_bytes = message.encode('utf-16le') + b'\x00\x00'
buffer = bytearray(476)
# hash: 4 bytes (0-4)
buffer[4] = 0x0b # (4-5)
# padding: 3 bytes (5-8)
buffer[8:8+len(nickname_bytes)] = nickname_bytes
buffer[30:34] = b'\x73\x00\x53\x00' # (30-34)
# session: 32 bytes (34-66)
# padding: 6 bytes (66-72)
buffer[72:72+len(message_bytes)] = message_bytes
struct.pack_into('@I', buffer, 0, hash_packet(buffer[4:]))
return buffer
messages = [
'Bravely bold Sir Robin',
'Rode forth from Camelot.',
'He was not afraid to die,',
'O brave Sir Robin.',
'He was not at all afraid',
'To be killed in nasty ways.',
'Brave, brave, brave, brave Sir Robin.',
'',
'He was not in the least bit scared',
'To be mashed into a pulp.',
'Or to have his eyes gouged out,',
'And his elbows broken.',
'To have his kneecaps split',
'And his body burned away,',
'And his limbs all hacked and mangled',
'Brave Sir Robin.',
'',
'His head smashed in',
'And his heart cut out',
'And his liver removed',
'And his bowels unplugged',
'And his nostrils raped',
'And his bottom burnt off',
]
def main(name):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
ping_message = make_ping_message(name)
encrypt_packet(ping_message)
print('Sending ping...')
sock.sendto(ping_message, ('255.255.255.255', 8086))
time.sleep(1)
for msg in messages:
chat_message = make_chat_message(name, msg)
encrypt_packet(chat_message)
print('Sending ping and chat...')
sock.sendto(ping_message, ('255.255.255.255', 8086))
sock.sendto(chat_message, ('255.255.255.255', 8086))
time.sleep(1)
print('Sending ping...')
sock.sendto(ping_message, ('255.255.255.255', 8086))
time.sleep(1)
if __name__ == '__main__':
main('sir_robin')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment