1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146
|
use sqlx::{Error, PgPool};
use std::{cmp, str};
// https://rustsec.org/advisories/RUSTSEC-2024-0363.html
#[sqlx::test(migrations = false, fixtures("./fixtures/rustsec/2024_0363.sql"))]
async fn rustsec_2024_0363(pool: PgPool) -> anyhow::Result<()> {
let overflow_len = 4 * 1024 * 1024 * 1024; // 4 GiB
// These three strings concatenated together will be the first query the Postgres backend "sees"
//
// Rather contrived because this already represents an injection vulnerability,
// but it's easier to demonstrate the bug with a simple `Query` message
// than the `Prepare` -> `Bind` -> `Execute` flow.
let real_query_prefix = "INSERT INTO injection_target(message) VALUES ('";
let fake_message = "fake_msg') RETURNING id;\0";
let real_query_suffix = "') RETURNING id";
// Our payload is another simple `Query` message
let real_payload =
"Q\0\0\0\x4DUPDATE injection_target SET message = 'you''ve been pwned!' WHERE id = 1\0";
// This is the value we want the length prefix to overflow to (including the length of the prefix itself)
// This will leave the backend's buffer pointing at our real payload.
let fake_payload_len = real_query_prefix.len() + fake_message.len() + 4;
// Pretty easy to see that this should overflow to `fake_payload_len`
let target_payload_len = overflow_len + fake_payload_len;
// This is the length we expect `injected_value` to be
let expected_inject_len = target_payload_len
- 4 // Length prefix
- real_query_prefix.len()
- (real_query_suffix.len() + 1 /* NUL terminator */);
let pad_to_len = expected_inject_len - 5; // Header for FLUSH message that eats `real_query_suffix` (see below)
let expected_payload_len = 4 // length prefix
+ real_query_prefix.len()
+ expected_inject_len
+ real_query_suffix.len()
+ 1; // NUL terminator
let expected_wrapped_len = expected_payload_len % overflow_len;
assert_eq!(expected_wrapped_len, fake_payload_len);
// This will be the string we inject into the query.
let mut injected_value = String::with_capacity(expected_inject_len);
injected_value.push_str(fake_message);
injected_value.push_str(real_payload);
// The Postgres backend reads the `FLUSH` message but ignores its contents.
// This gives us a variable-length NOP that lets us pad to the length we want,
// as well as a way to eat `real_query_suffix` without breaking the connection.
let flush_fill = "\0".repeat(9996);
let flush_fmt_code = 'H'; // note: 'F' is `FunctionCall`.
'outer: while injected_value.len() < pad_to_len {
let remaining_len = pad_to_len - injected_value.len();
// The max length of a FLUSH message is 10,000, including the length prefix.
let flush_len = cmp::min(
remaining_len - 1, // minus format code
10000,
);
// We need `flush_len` to be valid UTF-8 when encoded in big-endian
// in order to push it to the string.
//
// Not every value is going to be valid though, so we search for one that is.
'inner: for flush_len in (4..=flush_len).rev() {
let flush_len_be = (flush_len as i32).to_be_bytes();
let Ok(flush_len_str) = str::from_utf8(&flush_len_be) else {
continue 'inner;
};
let fill_len = flush_len - 4;
injected_value.push(flush_fmt_code);
injected_value.push_str(flush_len_str);
injected_value.push_str(&flush_fill[..fill_len]);
continue 'outer;
}
panic!("unable to find a valid encoding/split for {flush_len}");
}
assert_eq!(injected_value.len(), pad_to_len);
// The amount of data the last FLUSH message has to eat
let eat_len = real_query_suffix.len() + 1; // plus NUL terminator
// Push the FLUSH message that will eat `real_query_suffix`
injected_value.push(flush_fmt_code);
injected_value.push_str(str::from_utf8(&(eat_len as i32).to_be_bytes()).unwrap());
// The value will be in the buffer already.
assert_eq!(expected_inject_len, injected_value.len());
let query = format!("{real_query_prefix}{injected_value}{real_query_suffix}");
// The length of the `Query` message we've created
let final_payload_len = 4 // length prefix
+ query.len()
+ 1; // NUL terminator
assert_eq!(expected_payload_len, final_payload_len);
let wrapped_len = final_payload_len % overflow_len;
assert_eq!(wrapped_len, fake_payload_len);
let res = sqlx::raw_sql(&query)
// Note: the connection may hang afterward
// because `pending_ready_for_query_count` will underflow.
.execute(&pool)
.await;
if let Err(e) = res {
// Connection rejected the query; we're happy.
if matches!(e, Error::Protocol(_)) {
return Ok(());
}
panic!("unexpected error: {e:?}");
}
let messages: Vec<String> =
sqlx::query_scalar("SELECT message FROM injection_target ORDER BY id")
.fetch_all(&pool)
.await?;
// If the injection succeeds, `messages` will look like:
// ["you've been pwned!'.to_string(), "fake_msg".to_string()]
assert_eq!(
messages,
["existing message".to_string(), "fake_msg".to_string()]
);
// Injection didn't affect our database; we're happy.
Ok(())
}
|