Skip to content

Commit b2ff7a5

Browse files
committed
Add scanner article
1 parent 130a827 commit b2ff7a5

File tree

1 file changed

+239
-0
lines changed

1 file changed

+239
-0
lines changed

content/blog/scanner-minecraft.md

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,7 +1440,246 @@ The fuzzing should run after a normal `cargo test`.
14401440

14411441
{{ hr(data_content="the minecraft protocol - can't scan minecraft servers without it!") }}
14421442
The scanner in `main.rs` currently doesn't do much scanning at all - it just sends an HTTP request.
1443+
The minecraft protocol is documented by the community at [wiki.vg](https://wiki.vg/) which says there two types of pings - 1.6 and below, and 1.7+ (after the netcode was rewritten to use netty).
1444+
Previous scanners seem to use the 1.7+ ping because most servers use it - however, taking advantage of diminishing returns from reducing round-trips after the one round trip forces us to deal with all sorts of complications, we can first send a 1.6 ping (which the wiki says all servers should respond to) then send a 1.7 ping latter.
1445+
So, let's implement 1.6 first:
1446+
```rust
1447+
const MINECRAFT_1_6_PING: [u8; 26] = [
1448+
0xfe, 0x01, 0xfa, 0x00, 0x0b, 0x00, 0x4d, 0x00, 0x43, 0x00, 0x7c, 0x00, 0x50, 0x00, 0x69, 0x00,
1449+
0x6e, 0x00, 0x67, 0x00, 0x48, 0x00, 0x6f, 0x00, 0x73, 0x00,
1450+
];
1451+
const PROTOCOL_VER_1_6: u16 = 76;
1452+
1453+
// https://wiki.vg/Server_List_Ping#1.6
1454+
/// Builds a 1.6 or earlier legacy server list ping packet
1455+
pub fn construct_1_6_ping(hostname: &str, port: u16) -> Vec<u8> {
1456+
let encoded_string = hostname
1457+
.encode_utf16()
1458+
.flat_map(|unit: u16| unit.to_be_bytes())
1459+
.collect::<Vec<u8>>();
1460+
let mut ping_vec: Vec<u8> =
1461+
Vec::with_capacity(MINECRAFT_1_6_PING.len() + encoded_string.len() + 7);
1462+
1463+
// Header
1464+
ping_vec[0..MINECRAFT_1_6_PING.len()].copy_from_slice(&MINECRAFT_1_6_PING);
1465+
// Length of rest of message: 7 + len(hostname)
1466+
ping_vec[MINECRAFT_1_6_PING.len()..MINECRAFT_1_6_PING.len() + 2]
1467+
.copy_from_slice(&(encoded_string.len() as u16 + 7).to_be_bytes());
1468+
// Protocol version
1469+
ping_vec[MINECRAFT_1_6_PING.len() + 2..MINECRAFT_1_6_PING.len() + 4]
1470+
.copy_from_slice(&PROTOCOL_VER_1_6.to_be_bytes());
1471+
// Length of hostname
1472+
ping_vec[MINECRAFT_1_6_PING.len() + 2..MINECRAFT_1_6_PING.len() + 4]
1473+
.copy_from_slice(&(encoded_string.len() as u16).to_be_bytes());
1474+
// Hostname
1475+
let four_before_end = ping_vec.len() - 4;
1476+
ping_vec[MINECRAFT_1_6_PING.len() + 4..four_before_end].copy_from_slice(&encoded_string);
1477+
// Port
1478+
ping_vec[four_before_end..].copy_from_slice(&(port as i32).to_be_bytes()); // Mojang is quirky and decides ports are C ints now
1479+
1480+
ping_vec
1481+
}
1482+
```
1483+
All this does is add the ping header, the protocol version, the hostname and the port together. Technically only the first three bytes are needed for any server to respond (nothing else is actually used) - but just in case (for example some strange custom servers on a crusade to enforce client protocol compliance), the full header is included. I don't think the one allocation will be a bottleneck, and better not to do premature optimization. However, if it indeed improves performance, sending just the three bytes could have the potential to greatly improve performance.
1484+
1485+
The 1.7 ping is similar:
1486+
```rust
1487+
const NETTY_STATUS_ID: u8 = 0;
1488+
const NETTY_PROTOCOL_VER: u8 = 0;
1489+
const STATUS_REQUEST_STATE: u8 = 1;
1490+
1491+
// https://wiki.vg/Server_List_Ping#Current_.281.7.2B.29
1492+
/// Constructs a 1.7+ netty minecraft SLP packet
1493+
pub fn construct_netty_ping(hostname: &str, port: u16) -> Vec<u8> {
1494+
let mut ping_vec: Vec<u8> = Vec::with_capacity(hostname.len() + 5);
1495+
1496+
ping_vec[0] = NETTY_STATUS_ID;
1497+
ping_vec[1] = NETTY_PROTOCOL_VER;
1498+
ping_vec[2..hostname.len() + 2].copy_from_slice(hostname.as_bytes());
1499+
1500+
let server_port_slice = ping_vec.len() - 3..ping_vec.len() - 1;
1501+
ping_vec[server_port_slice].copy_from_slice(&port.to_be_bytes());
1502+
1503+
let last_element = ping_vec.len();
1504+
ping_vec[last_element] = STATUS_REQUEST_STATE;
1505+
1506+
ping_vec
1507+
}
1508+
```
1509+
1510+
Pretty much the same data is sent.
1511+
1512+
Now onto receiving, let's define the data structures we want first:
1513+
```rust
1514+
#[derive(Debug, Clone)]
1515+
pub enum MinecraftSlp {
1516+
Legacy(LegacyPingResponse),
1517+
Netty(NettyPingResponse),
1518+
}
1519+
1520+
#[derive(Debug, Clone)]
1521+
pub struct LegacyPingResponse {
1522+
pub protocol_version: Option<i64>,
1523+
pub server_version: Option<String>,
1524+
pub motd: Option<String>,
1525+
pub current_players: Option<i64>,
1526+
pub max_players: Option<i64>,
1527+
}
1528+
1529+
#[derive(Debug, Clone)]
1530+
pub struct NettyPingResponse {
1531+
version_name: Option<String>,
1532+
protocol: Option<i64>,
1533+
max_players: Option<i64>,
1534+
online_players: Option<i64>,
1535+
online_sample: Vec<Player>,
1536+
motd: Option<String>,
1537+
enforces_secure_chat: Option<bool>,
1538+
previews_chat: Option<bool>,
1539+
favicon: Option<String>,
1540+
}
1541+
1542+
#[derive(Debug, Clone)]
1543+
pub struct Player {
1544+
name: Option<String>,
1545+
id: Option<String>,
1546+
}
1547+
```
1548+
1549+
The idea is for the SLPs to try to be deserialized with both the 1.6 and 1.7 deserializers in succession, so no state is required to be stored. You can read more on the wiki, but the jist of the 1.6 ping response is it's just a packed structure of info:
1550+
```rust
1551+
/// Processes a server's 1.6 or earlier legacy ping response
1552+
pub fn process_server_1_6_ping(packet: &[u8]) -> Option<LegacyPingResponse> {
1553+
// Check 0xFF packet ID
1554+
if packet.first()? != &0xff {
1555+
return None;
1556+
}
1557+
1558+
// Check §1\x00\x00 magic string
1559+
if packet.get(3..9)? != [00, 167, 00, 31, 00, 00] {
1560+
return None;
1561+
}
1562+
1563+
let null_delim_string = NullDelimitedString::new(bytemuck::pod_align_to(packet.get(9..)?).1);
1564+
let info_string = null_delim_string.fields();
1565+
1566+
Some(LegacyPingResponse {
1567+
protocol_version: info_string
1568+
.get(0)
1569+
.map(|s| String::from_utf16(s))
1570+
.and_then(Result::ok)
1571+
.and_then(|s| s.parse().ok()),
1572+
server_version: info_string.get(1).and_then(|s| String::from_utf16(s).ok()),
1573+
motd: info_string.get(1).and_then(|s| String::from_utf16(s).ok()),
1574+
current_players: info_string
1575+
.get(3)
1576+
.and_then(|s| String::from_utf16(s).ok())
1577+
.and_then(|s| s.parse().ok()),
1578+
max_players: info_string
1579+
.get(4)
1580+
.and_then(|s| String::from_utf16(s).ok())
1581+
.and_then(|s| s.parse().ok()),
1582+
})
1583+
}
1584+
1585+
// 16 bit word string composed of fields separated by \x00\x00 that the legacy ping uses
1586+
struct NullDelimitedString<'a> {
1587+
data: &'a [u16],
1588+
counter: usize,
1589+
}
1590+
1591+
impl<'a> NullDelimitedString<'a> {
1592+
fn new(data: &'a [u16]) -> Self {
1593+
NullDelimitedString { data, counter: 0 }
1594+
}
1595+
1596+
// Convert into \x00\x00 separated fields
1597+
fn fields(&self) -> Vec<&[u16]> {
1598+
let mut fields = Vec::with_capacity(5); // 5 fields in a correctly formed legacy ping response
1599+
1600+
self.data
1601+
.split(|&c| c == 0x00)
1602+
.for_each(|field| fields.push(field));
1603+
1604+
fields
1605+
}
1606+
}
1607+
```
1608+
1609+
And the 1.7 structure is just json:
1610+
1611+
```rust
1612+
/// Processes a 1.7+ netty SLP response
1613+
/// Needs mutability for simd_json performance
1614+
pub fn process_server_netty_ping(packet: &mut [u8]) -> Option<NettyPingResponse> {
1615+
// Check packet ID
1616+
if packet.first()? != &NETTY_STATUS_ID {
1617+
return None;
1618+
}
1619+
1620+
// The next 1-2 bytes are a length field as a varint (so if the first bit of the first byte is set then it's two bytes)
1621+
let string_start_index = if packet.get(1)? & 0b10000000 == 0 {
1622+
2
1623+
} else {
1624+
3
1625+
};
1626+
let json_response = simd_json::to_borrowed_value(packet.get_mut(string_start_index..)?).ok()?;
1627+
let json_response = json_response.as_object()?;
1628+
1629+
let version_object = json_response.get("version");
1630+
let version_object = version_object.as_object();
1631+
1632+
let players_object = json_response.get("players");
1633+
let players_object = players_object.as_object();
1634+
1635+
Some(NettyPingResponse {
1636+
version_name: version_object
1637+
.and_then(|version| version.get("name"))
1638+
.and_then(ValueAccess::as_str)
1639+
.map(str::to_owned),
1640+
protocol: version_object.and_then(|version| version.get("protocol")?.as_i64()),
1641+
max_players: players_object.and_then(|players| players.get("max")?.as_i64()),
1642+
online_players: players_object.and_then(|players| players.get("online")?.as_i64()),
1643+
online_sample: players_object
1644+
.and_then(|players| players.get("sample"))
1645+
.and_then(ValueAccess::as_array)
1646+
.map(|players_array| {
1647+
players_array
1648+
.iter()
1649+
.map(|player| Player {
1650+
name: player
1651+
.get("name")
1652+
.and_then(ValueAccess::as_str)
1653+
.map(str::to_owned),
1654+
id: player
1655+
.get("id")
1656+
.and_then(ValueAccess::as_str)
1657+
.map(str::to_owned),
1658+
})
1659+
.collect()
1660+
})
1661+
.unwrap_or_default(),
1662+
motd: json_response
1663+
.get("description")
1664+
.and_then(ValueAccess::as_str)
1665+
.map(str::to_owned),
1666+
enforces_secure_chat: json_response
1667+
.get("enforcesSecureChat")
1668+
.and_then(ValueAccess::as_bool),
1669+
previews_chat: json_response
1670+
.get("previewsChat")
1671+
.and_then(ValueAccess::as_bool),
1672+
favicon: json_response
1673+
.get("favicon")
1674+
.and_then(ValueAccess::as_str)
1675+
.map(str::to_owned),
1676+
})
1677+
}
1678+
```
1679+
1680+
The transmitting and receiving is easy from there - the most interesting part is getting around the lack of concurrent transmitting in the socket api using parking synchronization (so just a glorified dataless mutex). This is not an issue with receiving because (at least with ethernet) receivers are physically separate cables so concurrent use is allowed.
14431681

1682+
*Not continued! - I don't see a point to finishing unless someone wants to buy me a VPS to join the hundreds of servers scanning Minecraft servers already, but you're welcome to try completing it - all the "hard parts" are done.*
14441683

14451684
{{ hr(data_content="future ideas") }}
14461685
Assorted thoughts on extending this scanner

0 commit comments

Comments
 (0)