Skip to main content

max / makenotwork

11.5 KB · 320 lines History Blame Raw
1 //! The glibc floor of a binary, read out of its ELF version requirements.
2 //!
3 //! A dynamically linked binary records which symbol versions it needs in
4 //! `.gnu.version_r`, as a list of `GLIBC_x.y` names per shared object. The
5 //! highest of those is the oldest glibc that can load it, and comparing it
6 //! against a node's glibc answers "could this ever start there" before anything
7 //! is built or moved.
8 //!
9 //! ## This is the weaker check, deliberately, and it runs first
10 //!
11 //! [`crate::deploy`]'s `ldd_guard_script` already asks the stronger question on
12 //! the node: it runs that machine's own loader against the actual bytes, which
13 //! covers every shared library and every symbol version rather than glibc
14 //! alone, and answers "will this exec here" instead of "is this number smaller
15 //! than that one". Nothing here replaces it and nothing here weakens it.
16 //!
17 //! What this buys is *when*. The loader check happens on the node, after the
18 //! rsync, one step before the symlink swap. By then the bytes are built and
19 //! moved. A number comparison can happen before either, so the class of failure
20 //! that is knowable from two declared numbers is refused at the start of a
21 //! promote rather than most of the way through it.
22 //!
23 //! ## Reading the section rather than scanning for strings
24 //!
25 //! `GLIBC_2.39` appears in `.dynstr` as a plain string, so a byte scan of the
26 //! whole file finds it and is four lines long. It also finds any such string
27 //! that is merely *data* — a version this binary embeds for some other reason,
28 //! anything in a bundled asset — and a false positive here refuses a promote
29 //! that would have worked. Parsing the section that actually states the
30 //! requirement costs a hundred lines and cannot be fooled that way.
31 //!
32 //! ## What it does not read
33 //!
34 //! ELF64 little-endian only, which is the whole fleet (x86_64 and aarch64).
35 //! Anything else, and anything that is not an ELF at all, returns `None`:
36 //! "cannot verify" is not "known bad", the same call `arch_guard_script` makes
37 //! for an unmapped architecture. A static binary has no `.gnu.version_r` and
38 //! also returns `None`, which is correct rather than a gap: it needs no glibc.
39 //!
40 //! <!-- wiki: host-base-images -->
41
42 use std::cmp::Ordering;
43 use std::fmt;
44
45 /// A glibc version as two numbers, so `2.9` sorts below `2.39` rather than
46 /// above it the way the strings do.
47 ///
48 /// That is not a hypothetical: string comparison puts `2.4` above `2.39`, and
49 /// glibc's version names are exactly the shape where it goes wrong.
50 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51 pub struct GlibcVersion {
52 major: u32,
53 minor: u32,
54 }
55
56 impl GlibcVersion {
57 /// Parse `2.39`, or a whole `GLIBC_2.39` version name.
58 ///
59 /// Rejects anything else, including the other version names that share the
60 /// section (`GCC_3.0`, `GLIBCXX_3.4`), because only glibc's are being
61 /// compared against a node's `ldd --version`.
62 pub fn parse(s: &str) -> Option<Self> {
63 let s = s.strip_prefix("GLIBC_").unwrap_or(s);
64 let (major, minor) = s.split_once('.')?;
65 // A trailing third component (`2.39.1`) is not a shape glibc uses in a
66 // version name; refuse rather than silently reading the first two.
67 if minor.contains('.') {
68 return None;
69 }
70 Some(Self {
71 major: major.parse().ok()?,
72 minor: minor.parse().ok()?,
73 })
74 }
75 }
76
77 impl Ord for GlibcVersion {
78 fn cmp(&self, other: &Self) -> Ordering {
79 (self.major, self.minor).cmp(&(other.major, other.minor))
80 }
81 }
82
83 impl PartialOrd for GlibcVersion {
84 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
85 Some(self.cmp(other))
86 }
87 }
88
89 impl fmt::Display for GlibcVersion {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 write!(f, "{}.{}", self.major, self.minor)
92 }
93 }
94
95 const SHT_GNU_VERNEED: u32 = 0x6fff_fffe;
96 const SHDR_LEN: usize = 64;
97
98 /// The highest `GLIBC_` version this ELF requires, or `None` when there is
99 /// nothing to read: not an ELF, not ELF64 little-endian, statically linked, or
100 /// carrying no glibc version requirements.
101 ///
102 /// Never errors. Every unreadable shape is a `None` rather than a failure,
103 /// because the caller's question is "do I know this cannot run there" and an
104 /// unparseable file is not evidence that it cannot.
105 pub fn glibc_floor(bytes: &[u8]) -> Option<GlibcVersion> {
106 // e_ident: magic, then EI_CLASS (2 = ELF64) and EI_DATA (1 = little-endian).
107 if bytes.len() < SHDR_LEN || &bytes[..4] != b"\x7fELF" || bytes[4] != 2 || bytes[5] != 1 {
108 return None;
109 }
110 let e_shoff = u64_at(bytes, 0x28)? as usize;
111 let e_shentsize = u16_at(bytes, 0x3A)? as usize;
112 let e_shnum = u16_at(bytes, 0x3C)? as usize;
113 // A section header table whose entries are not the size this parser knows is
114 // not one to walk with a fixed stride.
115 if e_shentsize != SHDR_LEN || e_shoff == 0 || e_shnum == 0 {
116 return None;
117 }
118
119 let shdr = |i: usize| -> Option<&[u8]> {
120 let start = e_shoff.checked_add(i.checked_mul(SHDR_LEN)?)?;
121 bytes.get(start..start.checked_add(SHDR_LEN)?)
122 };
123
124 let mut best: Option<GlibcVersion> = None;
125 for i in 0..e_shnum {
126 let sh = shdr(i)?;
127 if u32_at(sh, 4)? != SHT_GNU_VERNEED {
128 continue;
129 }
130 let vn_off = u64_at(sh, 24)? as usize;
131 let vn_size = u64_at(sh, 32)? as usize;
132 // sh_link names the string table the version names are offsets into.
133 let strtab = shdr(u32_at(sh, 40)? as usize)?;
134 let str_off = u64_at(strtab, 24)? as usize;
135 let str_size = u64_at(strtab, 32)? as usize;
136 let strs = bytes.get(str_off..str_off.checked_add(str_size)?)?;
137 let verneed = bytes.get(vn_off..vn_off.checked_add(vn_size)?)?;
138
139 for name in verneed_names(verneed, strs) {
140 if let Some(v) = GlibcVersion::parse(&name) {
141 best = Some(best.map_or(v, |b: GlibcVersion| b.max(v)));
142 }
143 }
144 }
145 best
146 }
147
148 /// Walk the `Verneed` chain and its `Vernaux` entries, yielding every version
149 /// name referenced.
150 ///
151 /// Both chains are `next`-offset linked lists that a malformed (or hostile)
152 /// file could point in a circle, so both are bounded by the number of entries
153 /// the headers claim and refuse a zero `next`, which is the shape a loop takes.
154 fn verneed_names(verneed: &[u8], strs: &[u8]) -> Vec<String> {
155 const VERNEED_LEN: usize = 16;
156 const VERNAUX_LEN: usize = 16;
157 let mut names = Vec::new();
158 let mut vn = 0usize;
159 // One pass per entry at most; the list cannot be longer than the section.
160 for _ in 0..=(verneed.len() / VERNEED_LEN) {
161 let Some(entry) = vn
162 .checked_add(VERNEED_LEN)
163 .and_then(|end| verneed.get(vn..end))
164 else {
165 break;
166 };
167 let Some(vn_cnt) = u16_at(entry, 2) else {
168 break;
169 };
170 let Some(vn_aux) = u32_at(entry, 8) else {
171 break;
172 };
173 let Some(vn_next) = u32_at(entry, 12) else {
174 break;
175 };
176
177 let Some(mut aux) = vn.checked_add(vn_aux as usize) else {
178 break;
179 };
180 for _ in 0..vn_cnt {
181 let Some(a) = aux
182 .checked_add(VERNAUX_LEN)
183 .and_then(|end| verneed.get(aux..end))
184 else {
185 break;
186 };
187 let Some(vna_name) = u32_at(a, 8) else { break };
188 if let Some(name) = cstr_at(strs, vna_name as usize) {
189 names.push(name);
190 }
191 let Some(vna_next) = u32_at(a, 12) else { break };
192 if vna_next == 0 {
193 break;
194 }
195 let Some(next) = aux.checked_add(vna_next as usize) else {
196 break;
197 };
198 aux = next;
199 }
200
201 if vn_next == 0 {
202 break;
203 }
204 let Some(next) = vn.checked_add(vn_next as usize) else {
205 break;
206 };
207 vn = next;
208 }
209 names
210 }
211
212 fn cstr_at(strs: &[u8], off: usize) -> Option<String> {
213 let rest = strs.get(off..)?;
214 let end = rest.iter().position(|&b| b == 0)?;
215 Some(String::from_utf8_lossy(&rest[..end]).into_owned())
216 }
217
218 fn u16_at(b: &[u8], off: usize) -> Option<u16> {
219 Some(u16::from_le_bytes(b.get(off..off + 2)?.try_into().ok()?))
220 }
221
222 fn u32_at(b: &[u8], off: usize) -> Option<u32> {
223 Some(u32::from_le_bytes(b.get(off..off + 4)?.try_into().ok()?))
224 }
225
226 fn u64_at(b: &[u8], off: usize) -> Option<u64> {
227 Some(u64::from_le_bytes(b.get(off..off + 8)?.try_into().ok()?))
228 }
229
230 #[cfg(test)]
231 mod tests {
232 use super::*;
233
234 #[test]
235 fn versions_order_numerically_not_lexically() {
236 let v = |s: &str| GlibcVersion::parse(s).expect("must parse");
237 // The whole reason this is two numbers: as strings, "2.4" > "2.39".
238 assert!(v("2.39") > v("2.4"));
239 assert!(v("2.39") > v("2.9"));
240 assert!(v("2.42") > v("2.39"));
241 assert_eq!(v("GLIBC_2.39"), v("2.39"));
242 assert_eq!(v("2.39").to_string(), "2.39");
243 }
244
245 #[test]
246 fn only_glibc_version_names_parse() {
247 // These share `.gnu.version_r` with glibc's and must not be compared
248 // against a node's glibc.
249 for other in [
250 "GCC_3.0",
251 "GLIBCXX_3.4",
252 "CXXABI_1.3",
253 "",
254 "GLIBC_",
255 "2.39.1",
256 ] {
257 assert!(
258 GlibcVersion::parse(other).is_none(),
259 "`{other}` must not read as a glibc version"
260 );
261 }
262 }
263
264 #[test]
265 fn a_non_elf_reads_as_unknown_rather_than_failing() {
266 assert_eq!(glibc_floor(b"not an elf at all"), None);
267 assert_eq!(glibc_floor(&[]), None);
268 // ELF32, and ELF64 big-endian: both real shapes this parser declines.
269 let mut elf32 = vec![0u8; 128];
270 elf32[..4].copy_from_slice(b"\x7fELF");
271 elf32[4] = 1;
272 elf32[5] = 1;
273 assert_eq!(glibc_floor(&elf32), None);
274 elf32[4] = 2;
275 elf32[5] = 2;
276 assert_eq!(glibc_floor(&elf32), None);
277 }
278
279 /// A truncated or malformed ELF must return `None` rather than panic. This
280 /// walks every prefix of a real binary, which is the cheap way to cover the
281 /// bounds checks in one test.
282 #[test]
283 fn truncation_at_any_length_is_unknown_rather_than_a_panic() {
284 let Some(real) = a_real_binary() else {
285 return;
286 };
287 for cut in [0, 1, 4, 16, 63, 64, 65, 1024, real.len() / 2] {
288 let _ = glibc_floor(&real[..cut.min(real.len())]);
289 }
290 }
291
292 /// The real thing: this test binary is an ELF built on this host, so it must
293 /// report a floor, and that floor must be one this machine can satisfy.
294 ///
295 /// Skipped rather than failed where there is no readable binary to point at,
296 /// so the suite still runs somewhere this does not apply.
297 #[test]
298 fn a_real_binary_reports_a_plausible_floor() {
299 let Some(bytes) = a_real_binary() else {
300 return;
301 };
302 let Some(floor) = glibc_floor(&bytes) else {
303 // A fully static test binary is legitimate and has no floor.
304 return;
305 };
306 assert!(
307 floor > GlibcVersion::parse("2.0").expect("must parse"),
308 "a real binary's floor should be a real version, got {floor}"
309 );
310 assert!(
311 floor < GlibcVersion::parse("9.0").expect("must parse"),
312 "a real binary's floor should not be from the future, got {floor}"
313 );
314 }
315
316 fn a_real_binary() -> Option<Vec<u8>> {
317 std::fs::read(std::env::current_exe().ok()?).ok()
318 }
319 }
320