mirror of
https://github.com/xzcrpw/blackwall.git
synced 2026-04-26 12:16:20 +02:00
232 lines
7.2 KiB
Rust
Executable file
232 lines
7.2 KiB
Rust
Executable file
//! MySQL honeypot: fake database server.
|
|
//!
|
|
//! Implements enough of the MySQL wire protocol to capture credentials
|
|
//! and log attacker queries. Simulates MySQL 8.0 authentication.
|
|
|
|
use std::net::SocketAddr;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpStream;
|
|
|
|
/// MySQL server version string.
|
|
const SERVER_VERSION: &[u8] = b"8.0.36-0ubuntu0.24.04.1";
|
|
/// Connection ID counter (fake, per-session).
|
|
const CONNECTION_ID: u32 = 42;
|
|
/// Maximum commands to accept before disconnect.
|
|
const MAX_COMMANDS: u32 = 50;
|
|
/// Read timeout per command.
|
|
const CMD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
|
|
|
/// Handle a MySQL client connection.
|
|
pub async fn handle_connection(stream: &mut TcpStream, addr: SocketAddr) -> anyhow::Result<()> {
|
|
// Step 1: Send server greeting (HandshakeV10)
|
|
send_server_greeting(stream).await?;
|
|
|
|
// Step 2: Read client auth response
|
|
let mut buf = [0u8; 4096];
|
|
let n = tokio::time::timeout(CMD_TIMEOUT, stream.read(&mut buf))
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("auth timeout"))??;
|
|
|
|
if n < 36 {
|
|
// Too short for a real auth packet
|
|
return Ok(());
|
|
}
|
|
|
|
// Extract username from auth packet (starts at offset 36 in Handshake Response)
|
|
let username = extract_null_string(&buf[36..n]);
|
|
tracing::info!(
|
|
attacker_ip = %addr.ip(),
|
|
username = %username,
|
|
"MySQL auth attempt captured"
|
|
);
|
|
|
|
// Step 3: Send OK (always succeed — capture what they do next)
|
|
send_ok_packet(stream, 2).await?;
|
|
|
|
// Step 4: Command loop — capture queries
|
|
let mut cmd_count = 0u32;
|
|
loop {
|
|
if cmd_count >= MAX_COMMANDS {
|
|
tracing::info!(attacker_ip = %addr.ip(), "MySQL max commands reached");
|
|
break;
|
|
}
|
|
|
|
let n = match tokio::time::timeout(CMD_TIMEOUT, stream.read(&mut buf)).await {
|
|
Ok(Ok(n)) if n > 0 => n,
|
|
_ => break,
|
|
};
|
|
|
|
if n < 5 {
|
|
continue;
|
|
}
|
|
|
|
let cmd_type = buf[4];
|
|
match cmd_type {
|
|
// COM_QUERY (0x03)
|
|
0x03 => {
|
|
let query = String::from_utf8_lossy(&buf[5..n]);
|
|
tracing::info!(
|
|
attacker_ip = %addr.ip(),
|
|
query = %query,
|
|
"MySQL query captured"
|
|
);
|
|
|
|
// Send a fake empty result set for all queries
|
|
send_empty_result(stream, buf[3].wrapping_add(1)).await?;
|
|
}
|
|
// COM_QUIT (0x01)
|
|
0x01 => break,
|
|
// COM_INIT_DB (0x02) — database selection
|
|
0x02 => {
|
|
let db_name = String::from_utf8_lossy(&buf[5..n]);
|
|
tracing::info!(
|
|
attacker_ip = %addr.ip(),
|
|
database = %db_name,
|
|
"MySQL database select"
|
|
);
|
|
send_ok_packet(stream, buf[3].wrapping_add(1)).await?;
|
|
}
|
|
// Anything else — OK
|
|
_ => {
|
|
send_ok_packet(stream, buf[3].wrapping_add(1)).await?;
|
|
}
|
|
}
|
|
|
|
cmd_count += 1;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Send the MySQL server greeting packet (HandshakeV10).
|
|
async fn send_server_greeting(stream: &mut TcpStream) -> anyhow::Result<()> {
|
|
let mut payload = Vec::with_capacity(128);
|
|
|
|
// Protocol version
|
|
payload.push(10); // HandshakeV10
|
|
|
|
// Server version string (null-terminated)
|
|
payload.extend_from_slice(SERVER_VERSION);
|
|
payload.push(0);
|
|
|
|
// Connection ID (4 bytes LE)
|
|
payload.extend_from_slice(&CONNECTION_ID.to_le_bytes());
|
|
|
|
// Auth plugin data part 1 (8 bytes — scramble)
|
|
payload.extend_from_slice(&[0x3a, 0x23, 0x5c, 0x7d, 0x1e, 0x48, 0x5b, 0x6f]);
|
|
|
|
// Filler
|
|
payload.push(0);
|
|
|
|
// Capability flags lower 2 bytes (CLIENT_PROTOCOL_41, CLIENT_SECURE_CONNECTION)
|
|
payload.extend_from_slice(&[0xff, 0xf7]);
|
|
|
|
// Character set (utf8mb4 = 45)
|
|
payload.push(45);
|
|
|
|
// Status flags (SERVER_STATUS_AUTOCOMMIT)
|
|
payload.extend_from_slice(&[0x02, 0x00]);
|
|
|
|
// Capability flags upper 2 bytes
|
|
payload.extend_from_slice(&[0xff, 0x81]);
|
|
|
|
// Auth plugin data length
|
|
payload.push(21);
|
|
|
|
// Reserved (10 zero bytes)
|
|
payload.extend_from_slice(&[0; 10]);
|
|
|
|
// Auth plugin data part 2 (12 bytes + null)
|
|
payload.extend_from_slice(&[0x6a, 0x4e, 0x21, 0x30, 0x55, 0x2a, 0x3b, 0x7c, 0x45, 0x19, 0x22, 0x38]);
|
|
payload.push(0);
|
|
|
|
// Auth plugin name
|
|
payload.extend_from_slice(b"mysql_native_password");
|
|
payload.push(0);
|
|
|
|
// Packet header: length (3 bytes LE) + sequence number (1 byte)
|
|
let len = payload.len() as u32;
|
|
let mut packet = Vec::with_capacity(4 + payload.len());
|
|
packet.extend_from_slice(&len.to_le_bytes()[..3]);
|
|
packet.push(0); // Sequence 0
|
|
packet.extend_from_slice(&payload);
|
|
|
|
stream.write_all(&packet).await?;
|
|
stream.flush().await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Send a MySQL OK packet.
|
|
async fn send_ok_packet(stream: &mut TcpStream, seq: u8) -> anyhow::Result<()> {
|
|
let payload = [
|
|
0x00, // OK marker
|
|
0x00, // affected_rows
|
|
0x00, // last_insert_id
|
|
0x02, 0x00, // status flags (SERVER_STATUS_AUTOCOMMIT)
|
|
0x00, 0x00, // warnings
|
|
];
|
|
|
|
let len = payload.len() as u32;
|
|
let mut packet = Vec::with_capacity(4 + payload.len());
|
|
packet.extend_from_slice(&len.to_le_bytes()[..3]);
|
|
packet.push(seq);
|
|
packet.extend_from_slice(&payload);
|
|
|
|
stream.write_all(&packet).await?;
|
|
stream.flush().await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Send an empty result set (column count 0).
|
|
async fn send_empty_result(stream: &mut TcpStream, seq: u8) -> anyhow::Result<()> {
|
|
// Column count packet (0 columns = empty result)
|
|
let col_payload = [0x00]; // 0 columns
|
|
let len = col_payload.len() as u32;
|
|
let mut packet = Vec::with_capacity(4 + col_payload.len());
|
|
packet.extend_from_slice(&len.to_le_bytes()[..3]);
|
|
packet.push(seq);
|
|
packet.extend_from_slice(&col_payload);
|
|
|
|
// EOF packet
|
|
let eof_payload = [0xfe, 0x00, 0x00, 0x02, 0x00]; // EOF marker + warnings + status
|
|
let eof_len = eof_payload.len() as u32;
|
|
packet.extend_from_slice(&eof_len.to_le_bytes()[..3]);
|
|
packet.push(seq.wrapping_add(1));
|
|
packet.extend_from_slice(&eof_payload);
|
|
|
|
stream.write_all(&packet).await?;
|
|
stream.flush().await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Extract a null-terminated string from a byte slice.
|
|
fn extract_null_string(data: &[u8]) -> String {
|
|
let end = data.iter().position(|&b| b == 0).unwrap_or(data.len().min(64));
|
|
String::from_utf8_lossy(&data[..end]).to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn extract_username() {
|
|
let data = b"admin\x00extra_data";
|
|
assert_eq!(extract_null_string(data), "admin");
|
|
}
|
|
|
|
#[test]
|
|
fn extract_empty_string() {
|
|
let data = b"\x00rest";
|
|
assert_eq!(extract_null_string(data), "");
|
|
}
|
|
|
|
#[test]
|
|
fn extract_no_null() {
|
|
let data = b"root";
|
|
assert_eq!(extract_null_string(data), "root");
|
|
}
|
|
}
|