File: rustsec.rs

package info (click to toggle)
rust-sqlx 0.8.3-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 1,744 kB
  • sloc: sql: 335; python: 268; sh: 71; makefile: 2
file content (146 lines) | stat: -rw-r--r-- 5,484 bytes parent folder | download
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(())
}