From 8bbea22f745cd084a8c7eb586b39485f639e8007 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 06:26:15 +0300 Subject: [PATCH] Media: refuse MP4s whose sample tables overrun the file before symphonia reserves memory for them --- ATTRIBUTION.md | 1 + crates/media/src/audio.rs | 1 + crates/media/src/lib.rs | 1 + crates/media/src/mp4guard.rs | 76 ++++++++++++++++++ crates/media/src/probe.rs | 3 + crates/media/tests/decode.rs | 15 ++++ .../tests/fixtures/damaged/stsc-count.m4a | Bin 0 -> 5327 bytes .../tests/fixtures/damaged/stsz-size.m4a | Bin 0 -> 5324 bytes 8 files changed, 97 insertions(+) create mode 100644 crates/media/src/mp4guard.rs create mode 100644 crates/media/tests/fixtures/damaged/stsc-count.m4a create mode 100644 crates/media/tests/fixtures/damaged/stsz-size.m4a diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9ca8e5e8..31ed7495 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,6 +26,7 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene | `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots | | `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests | | `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests | +| `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort | | `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures | | `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests | | `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests | diff --git a/crates/media/src/audio.rs b/crates/media/src/audio.rs index 80ad66a4..96ab9b79 100644 --- a/crates/media/src/audio.rs +++ b/crates/media/src/audio.rs @@ -77,6 +77,7 @@ pub fn decode(bytes: &Bytes) -> Result { Some(r) => r, None => decode_symphonia(bytes, "mkv"), }, + Container::Mp4 if !crate::mp4guard::tables_fit(bytes) => Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into())), c => decode_symphonia(bytes, c.name()), } } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index d31b5d61..95150868 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -19,6 +19,7 @@ pub mod audio; pub mod clip; pub mod feed; +mod mp4guard; pub mod player; pub mod probe; pub mod video; diff --git a/crates/media/src/mp4guard.rs b/crates/media/src/mp4guard.rs new file mode 100644 index 00000000..ff8e8613 --- /dev/null +++ b/crates/media/src/mp4guard.rs @@ -0,0 +1,76 @@ +//! Checks an MP4's box tree before symphonia reads it. Symphonia's MP4 reader reserves memory +//! from the entry counts in the sample tables (and reads each sample whole at its `stsz` size) +//! without checking them against the file, so a damaged count asks for gigabytes and the process +//! aborts — an allocation failure cannot be caught. A file passes when every box fits inside its +//! parent, every table's entries fit inside its box and every sample fits inside the file. + +/// Boxes whose payload is a sequence of boxes. +const CONTAINERS: [&[u8; 4]; 11] = [b"moov", b"trak", b"mdia", b"minf", b"stbl", b"edts", b"dinf", b"udta", b"mvex", b"moof", b"traf"]; + +/// Whether symphonia can be given `b` (an MP4) without a damaged size reaching an allocation. +pub(crate) fn tables_fit(b: &[u8]) -> bool { + boxes_fit(b, b.len() as u64, 0) +} + +fn be32(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as u64) +} + +fn boxes_fit(b: &[u8], file_len: u64, depth: u32) -> bool { + if depth > 16 { + return false; + } + let mut at = 0usize; + while b.len() - at >= 8 { + let Some(size32) = be32(b, at) else { return false }; + let kind = &b[at + 4..at + 8]; + let (header, size) = match size32 { + 0 => (8, (b.len() - at) as u64), + 1 => match b.get(at + 8..at + 16) { + Some(s) => (16, u64::from_be_bytes(s.try_into().unwrap_or_default())), + None => return false, + }, + n => (8, n), + }; + if size < header as u64 || size > (b.len() - at) as u64 { + // A box that runs past the end of the file is fine at the top level (the file was cut + // short; symphonia stops there), but never inside a parent. + return depth == 0 && !matches!(kind, b"moov" | b"moof"); + } + let body = &b[at + header..at + size as usize]; + let ok = if CONTAINERS.iter().any(|c| c.as_slice() == kind) { + boxes_fit(body, file_len, depth + 1) + } else if kind == b"meta" { + // ISO `meta` is a full box (4 bytes of version and flags first); QuickTime's is not. + let skip = if be32(body, 0) == Some(0) { 4 } else { 0 }; + boxes_fit(&body[skip.min(body.len())..], file_len, depth + 1) + } else { + table_fits(kind, body, file_len) + }; + if !ok { + return false; + } + at += size as usize; + } + true +} + +/// A sample table (a full box: version and flags, then a count and that many entries). +fn table_fits(kind: &[u8], body: &[u8], file_len: u64) -> bool { + let entry = match kind { + b"stts" | b"ctts" | b"co64" => 8, + b"stsc" => 12, + b"stco" | b"stss" => 4, + b"stsz" => { + let (Some(size), Some(count)) = (be32(body, 4), be32(body, 8)) else { return false }; + if size != 0 { + return size <= file_len; + } + let Some(entries) = body.get(12..) else { return false }; + return count <= entries.len() as u64 / 4 + && entries.as_chunks::<4>().0.iter().take(count as usize).all(|s| u32::from_be_bytes(*s) as u64 <= file_len); + } + _ => return true, + }; + be32(body, 4).is_some_and(|count| count <= body.len().saturating_sub(8) as u64 / entry) +} diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 80a8a380..bc33eb17 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -58,6 +58,9 @@ pub fn probe(bytes: &Bytes) -> Result { }; if info.audio.is_none() && info.video.is_none() || info.audio.as_ref().is_some_and(|a| a.sample_rate == 0) { // Plain audio files (and anything our demuxers didn't describe): symphonia's probe. + if c == Container::Mp4 && !crate::mp4guard::tables_fit(bytes) { + return Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into())); + } let (codec, rate, channels, frames) = crate::audio::probe_symphonia(bytes, c.name()).ok_or_else(|| MediaError::Unsupported("this kind of media file".into()))?; info.audio = Some(AudioInfo { codec: codec_label(&codec), sample_rate: rate, channels, decodable: true }); diff --git a/crates/media/tests/decode.rs b/crates/media/tests/decode.rs index b3179445..b064a7e5 100644 --- a/crates/media/tests/decode.rs +++ b/crates/media/tests/decode.rs @@ -325,3 +325,18 @@ fn player_plays_decoded_audio_through_the_mixer() { assert!((rms - 0.0884 * 0.5).abs() < 0.005, "{rms}"); assert!((p.status(1).map(|s| s.position).unwrap_or(0.0) - 0.1).abs() < 1e-6); } + +/// MP4s whose sample tables were damaged by a mutation fuzzer (from `tone.m4a`, so CC0 like it): +/// one claims billions of `stsc` entries, one a 4 GB sample. Symphonia reserved that much memory +/// up front and the process aborted; now they are corrupt files. +#[test] +fn mp4_sample_tables_that_overrun_the_file_are_corrupt() { + for name in ["damaged/stsc-count.m4a", "damaged/stsz-size.m4a"] { + let b = fixture(name); + assert!(matches!(audio::decode(&b), Err(MediaError::Corrupt(_))), "{name}"); + let _ = probe::probe(&b); + } + for name in ["tone.m4a", "tone-alac.m4a", "clip-h264.mp4", "clip-h264.mov", "clip-hevc.mp4"] { + assert!(audio::decode(&fixture(name)).is_ok() || name.starts_with("clip"), "{name} still decodes"); + } +} diff --git a/crates/media/tests/fixtures/damaged/stsc-count.m4a b/crates/media/tests/fixtures/damaged/stsc-count.m4a new file mode 100644 index 0000000000000000000000000000000000000000..06cb3580ab3a3b99816c3f090a5a0106058e6379 GIT binary patch literal 5327 zcmai22UwHY(oUoc(m}d_NXMmvV4({LC|wAV7C;Cglz@Oz1cK6QC_$DY;+V-G6uQbD!s*aaO$$IJ0F|KmUZb`~C#w&Y0 zehXr>AJysTZpp0}9!(f4R}z~r+bs3NTjk(#`T;9jiT*nfK$;+VNxT3f+ zg`&9Npm;RmY$=-wrrT1?2Ip6G?(SNrMVz(tKxC9~vRhH9t8ns@0(~K@UMaSu6ss=; z@B)DR!9@$A&lSXkf}JzQ71tG5Vmz_Q^=ubUZtOHkq1)Hin{A;KYx^gGES9j-`nH2l z-H8s39R53#(QwB3F)=yvo6r=Y4A@~^PIMXL4QEJPyg%Q|7qu&?wVgNq<12+%LyJnmxmx_$)oqXQ66y zCEYrEED1%v{qSKWL)IN-n!AMwwEI`mROYZ{wx;#+wFrWhtO?d!7>@g_SD3{kQ~A;_ z*_E3|tYZALUUqdw_W1Z`9Y4QLO1}p#BmsCpe|d4tYVS7?HKX6rh(Ui8>Tu|);p&^c zvn$mFquQ|sD>!E3ODsFt%*-sZcT($3W`}IRjNc{qip0*FsrM(@Dr|kP8trR$o0l)s zBh-%QF^Np;S%zVLR$lCAsy%&nGXvUGwjJ-W)814jzSXhu=Jxb41LyA*hR*E>hDJcjP9A5w!0oqGjIPz4>{>@hZ6OJfLt9@p=fBh)9VMM! zr@J;)J?4ES6CGomclhcjm+SoKx8a0aGCLLZ3k=uUIN>i7&wLZ_<#hIZOz-^M>WcD~ z*T_s`CnR|9ehK*()xPAz2z9k9;&RW}X!yP)4>7mIfI~H{Mkt4r93L&T)b-o7OAo?X z{Q2D7o9e${nOO|fo?OUCobfh+s0cE8_un=U8;_GvWeUqM=^;3|M6{Jn!*Gt0Va}9w z2cRWI$!!Y*il}`1`*(|_&Qj?gA3q>|3!XV8S|7tJ23;V@YQz#>8H29ke51)Ot_>l_nefmcV?UhtZw!x#2Fi;W?+)hJPuwg*r zaRH+-?Ip`f<#QMYTL+a^zgC&`n2mAvJGyyZio{y{fD`+HCYYKmGTy3sCf!ygBQe=u zazR@4g+6;=Y2o@-1x#Fhltp`FjoWJin?c z$qE%4Z5Q&HG|Z4f{+hV5&FxXvl%OWA263!jA@)^a=1M7Vjw@$5=hB-)I>~%4vk6nExUIyRq5cR+o_Vq|K5qTWxd(BNEpL1*S9Ip@WR&NI~oT{cjzKo)% zdo#H!DLMaquY<1V3v=O3Zo*<1mBMCCV`zeaFcnN1gW2z6XQ4K&sfJ}fzg&qgu|g-R z?j?T_{TNE=QkET?Gv4~MEuILPRFvG<(?L_@tF36Mu+n2roQbES&BPlYM{@#SmSIQ4 zIe+8#E|qZ$eNi7He4nZI1!O1iu=s~M+xX1*jMG%Ql~+=7d%B4PW{%c^x=URkRCKie zoees}8cf}09!ub19=s=;zyo$yWh*|v!U6HfO1YX*SNg+S&hHjfdaK!1X;emkV&n_$ z^9rF!b!75v&4!isQ(qpD(dFmnH^zB>Mr#}8;MZ<{+VCeG4a3d`6jTLvD;d_E6MfCZ z&xge2AG;4KO6A`_9GR(14= zI6JsrC*~a-x~pl^s(l;boo`ok{Hy3;Qj`q&_5$*fyl1I2S_fZEhn##R{vaAj%=^5l zjV{ha9?J;1o3&h@h1`XhIlb2vX*IrRj#s;PUF1o5uH4&6_@>B*`0cJVkUGU%QGdQB zvB-?7#V$>9Cnk+OLGEy}wy02!Z0V-xD1*e;Hgr$-An##b5Uo0$@gfub=Q@@?b~@MBvBg+Qc=QwS?d(j{bW>Ri(`Gp6q+)U1QZ z9`x@C=G@EjHT~K9=+M`9-|KOft1)2}WFFsJYymBhN`f7&71p1RXV~1|B7R>!A!cD; z&#*hccyyunp#9Uuvy5I<+-K$R#Wx+r#yE~|1wK=Kef0XM`fDfM!|F=xDvO_AAXK7B z2a{ijGYeg5v2zTN`#@>T!nrG}c6Gd{GyY~pOM|6_)!>iSB3(4-a5Y$qLvCZkI9j>Y z0lMBuj%r@Js@&sHRCJz?zgxO|qGPA>x|eViO2Vh+`NsBqo3o)-{|#WGQttk==QY9h zD|fAh=;Ok14|_vS`<98wqv73Ga%d>hx|sZ3rntFTGKNA9E#B}FqOoO0EK3X9{VBk8 zb1QOk;GTTYM{eu;tbx4ZSb{$)jn6l8nZqqSwnlIz#AumotaW{<=g zbmBNpA?|*U9yK$p16K+Yyw6pYVwHOqZR}IUT7Lr)j|F}^^#x4g)oC81(%y5 zE(D58vT3`*J42YAAo(E0SfhU2cA0Hb+Vd0%f8g{Yxq%mC3ybSd%x!Nz?MomU;R1SM zN@ue6{Ys=S$;i%{YZBi0GP4PZ5|t81<#FJe9L}SL)6L=0uTxb%mbZqW``K`-F5m3( zH9g3+ZS+R1_)WzBF($lY4M zhNG3(;CJ z(`veys;w0wK@XXWPyCDX156VthSCga~eHxF zH)Q(B7B6=}KqL3%Z6FIN$>Hl!6#43_Q@~Ycyze_PM4?03b)sSXnHC{IeV$O2($#Vgs@eL1p( zMLxqGCKIuYJ^`vaZZi+wRuq=?%loEAmoQX!@Q4hRYfsKu3{`&&>8=vQR^8{3&8D7Jo*W6jT}?4>}e+AZtM_w9-b;BjvymF z+o5Vmzmg;kL8@Z@)y>-d?pg$phEdTbD-x4f@_I9rAXXMHqu;d36_N>QmWu-9?@qt`Q29+}gU z5;Z>3Y?nzVnN0*Ul~${|>kR%jvHp;_b-aH4Dm)C)?r2qB7FwK3tU`JLI~xe zW+ez}=WB0Q8L|bQHlXtI^1|BM+Hht(bZrXx%!Pp&;81a2Evh%)i32xN;5EcqJ``h1KDCku^=-Pw56rI?Gx7|L8pFU@B0qM)Y~S; zqm70Y^QEhm*zt`(J>IkF4cM1LWOy4D z8ZGcWb*s+_v~Xlme@SfKqLPd0!_lS({;!zEG$tfi#S=etv)sq>D`-=Gjg$v7y0MV8 zz5?C9FU?jXcPgp+#pb%d4DGfU4OdDZYa5_!ovIWb*)+HW5e@uI`<}SnG!^UvvgE?|F{auwPCStnaNp+Lytz zZ*?c!HOh%yKJVX+HzWrN9^0qC$ay;>q=!P`uzGKuCJLD>U<2CC{Y;>NOo_DZw$)e9 z-vpfmJl6J8^KsBNjL~L*j=QOtf$Y{&OBz#7n{W|lY^wPYD<72W2rueG!GelXAHvvM zsiTMn#9USUVB;eeh1%&k-DHg$sk0#|$GN8=EIT{{d=Fpe?sh*mnY6vvNw+0&$ft=LB?@<~=$M zxskICyr=+y?6;l(QZcCpa7y}TH%>!3-@feuxq|4ye2P-2BAE-i>ivl*B-Q6r!t%_C zxX~3U@e1LEP)`O1@`97^*bz$Ku zBr_}@E4TRcV2$}ONeuYkOVy}}yr_NmNND4bCzPS$53q}PzmjchIQj})nzo0}W>VF!SKub-ZtZif;(JKx&T zk=S)^5Z&n#NMvT34+J8UMIe!WKz{BBzxyyi|3k{PKp+c25Ew-I{w@D%0OtQ{tNrcy zzt|bz4bWqhw>=!_v_ZjtjY;+o$3LS1a3$O(q>0YdONx%;603?nLqd z7b09eoB_hw9+LYyDsHWoFMzX3s9C0sz48h6(mDR?-v#fMWnfdibMJ zAQb=rpm+0gbp-&nKor6W1L3x;!9)wwS(PlDdpT;{UR1PfF1ogw0x)Q4a&(A(N`Wyb zuyMZ<<<9h~TEhvGcrHwL47_mMk|2G5_{94Hep&LKR=oim&k517mxu=`!t-PIqB~?5 zMACL!qxV^eckVAuH++LfO~U=V?63HurUV`ZSe-6>a=+%8c+O+*u5q=ij$dTW<9u^h z64zlSJ2BlZUeXAOX7>0_SVBoc zrE*C^*quZy>UagG8Lrn_(w-1ld%|Y9+bY3M?rn68SgL19g@;(`{USqAyg>!NyaI11 z3hG6m@|P}A6f02_7Xfk08dq6SW{>m1r#5k(KD@lyB8%-@UTJlJ(=6{ChOk>Bj+#1l z-}fdvH**JVPR1fx=f@-!sjtA(M6(ckjd`(^te4$j35h|1pZ1*+_16V>-;I=bItU{GZ`pLvTxbx=`gGqy+sn$uk^4Mz{JWWif1Hxj)!J6kHH*1J}pU7J2&;rX-L*sT)#{AA_W8+K<_MZIY^_U<1Hk@=-ezUrXrD&D)3o`XNZ=s;aEU>5|^H!FLQNR7I z9L7sPs@a`9z8`-1V7pm9xLg5wY7`fMlRYP8(IZ|?b0}0PmHjNy*Y)_xxc>RMrA5_s z-;tT-Zdlm%&2s9G>I12J_cS%mODR6&WDxwAIs|QtgG6ZCj?fOBa|!*_);Q?cDfbS^ z9wg}H)zb744`nyfxPK}udB)ESrY6GbH+aoRay&s=oh>rUtdHdCey^i)8bNT8iFBiF z+y%a<$ZS{{(Zm!w-Lxr_Jx*tMaBz$KEo|n9Y`9R|UJMFpu_?2_tct7*){Z}>P~@^=KDl9qJTL)2FdG`ECe6_PLH$aLx*)K& zR;F6bUe}#^CIdI5T(~TyYJY8%BkfKL?{g`k!$G&ejmiZL!Jf+o>NV-bB#4SLVj&jko{oNF)Q3Dl)6vdRUr5jYSJ(|K15~G7J?Xg*Q5c&?wcoIMK-3^5#eu$SkXW7X`ZkTsY+J&sfiXVQ8fmh*j z>#YvwM&%7BMm{n=surEpM5oTyuiEN92;dhVU3g@9d7S@etgcBOarxT&)ga2zFzjYT zLs#Uum}S>JG0;l>ut#3_v30AuLh04*k(nBWB}3d94xTu(_YoK z5dIo1=%)q^m8TOVI3f+YaW9eZEp7XD-D@bnLdTMWUz;8wOUqK|B&;MOaGXx7{oRx4 z@WZdnnZncGJ_AM2#Dl#P3(-E54XSu8F@+ z+~~(5W1E6Y^Gf6ZOIq|^#=L}5&SdX9JKKJ1@KmV66J^@F)P*odb#uMBvqG}!RU@^^21ZUa71wgeYeCvvcm z$g;MxPX4}dNY24Oo#ApT4L#Mr+xh-G91Y_qChp zc3lmAi9Ikd1TNj8hbt^5SX3^yIl2TZzNR&0=h;%wxG-MQop`0X?YXs;?YkdKCHh!k zZz)WNTXA*OG*-3U8NSj?jcHxJpxWnLQgTvIs8_CPqHD9}lCM|{M%usr(dx#0hnum^ z;AOB-S+5|*liCocMH@R&=7cE1?f&qifdw*pe|YPOA{LIeE2DmyEoEtuilb4)Nqhd?y!a+Y^vmwQK;pxPe`5kowY9B;PreaiPxXsx#y1w0;VQV_lejH*9_T6+Kr-q1<=(LH~utgbo&J$=R z-k>c==ueJH(l>ZAcJZaQ^7Xv&hHL{p=)i*s@6Qin$1UakkXrX1^Z`WWToU(c*{f)| z`>~;;7RDD^vJ-+^w@ zMAkEuNo3}yUDJ|XyOuDwgkn_D)6EJO#rT;A(&lvrDfhD9d~Jf+SLhq8g=Bxdr9{|G z&CSdRvaTOa3%6+qe7?UJUv|9pY~{

H*aFPkSpvA7D;JmWiOw4ju#r0e=i7$+tbq{P3%D8E|R6CmpuDNV&!1O5-KoQ&2J$mBh&>$8g=Lk>_Nh3fo1TL>xQ7AD|A}Ct_Ees zQW1h4k`Lp_GY1}J3z6PC$<2>8H_HQXx51rnEJIX-Yq`FJ^+xeF*BqYEXzkbqaJbD;z7Z1l$xnTQFr3~W}S!NUI1@z60!HeYvh=kbzdu) zaCwu=fBQ%|c?2Ep(+Sr=2bQNOxF*@HS(PH66o=f&cOHwd7`|(mXr&)zsh6f~t5`O- zZD8!BXrM?$!9?HRWqllQ_bIzou}%kzvX8*9VwrWkgw4 zL6%$vA>{K)3Q9B|Gb>HfIN5Nm)|fNo=sBjKpdhlNqk~|<&(xt@2rUlDf<#CK=+HHq zbDH$aL?4r&WmbV9KLJ=|3ZTwrv5hO6)jX3&E62j(gB5lB9KsnVfd?!w@V2(9j`zHi zB)z6j+ut{F=6?2Z-W?1aI3J#5`KT!|HGJ&zW$y%W%4~1znPcPc4;Ek~1bFDQc3|ZF zZ1TXI>eRiy5p9tY(XwOrzC?E}iF+9y`=VJTpVr7%RsKCYW0La(`|Dm_iIWveHTdz> z5CehZna}Z$MX8APY79D%8=AHsk{FTblEL!$g3ssN&2Nvk+zNWaHl{Tp%^{Wix|jVX zUPxJ&_G`2fgw>Oss{ILY^QIhUz2cFK`bYaqLGp|nk_^0Q1Bk7W-k>7JPX{J}&o%Vi zXkP?ABpAt(s9JB7SsJo@K6jdmn;r=(y?DVl`HQdRv02KE!WyRu_C#@547qfPTsvbe zW-Wl`cP0{%QY)u^=oE1Eb6~y=b$WMm#v2MW9dTkk->!k5O1<(w@#WauTrBnEbZqRS zif>NWTS;t8v~JL>h*#hKe74KfrWNU|HS!h}qh19&&3*bF`f8DTLSuIP`-%WKbMr;s zcLmYG?{n6hMKW+AFFRkzB|aefJc#e8ws+!`lz_R=yfn)aADT1yL{vB(I~LJUh3KEk znwvnez~zhiK3_DLgQt&jJQHl-|8@2zNS%m`8Z6Ye<6w@DDh$KrA5D{g9{P;HS5$Yd zsXs>g3t5?qNT#b!V2Vsb+!EV+whlw9sHgbPP9443M@{thp-DZxo5k^Vx7$AIz167r$^asF!F=4Q_b+fZ=DJYgyraG|GE*+baw)=u}~Q zU@QLvi2*hx-m%ruR5yPGI1Ik48>r#$tZN*n%K{(wRI>mam($Ce(~epQ_m0`u385BW zt2UBKn<5}0DzdL5x!UPt$VTLRb>h3`PL}e_e(|P{QP{LU)ep~an}r6 z*K{3#w_+hLxVNGp^eFst-Uj%h0sx%W?}MnG)Btr_=7*P$!n{DtIhkY`upi?O($>*^9P*m%}e&f4!}44InwWzgWsF7M`YbEBF8p}}$x8XBrX*V@|pTnMoX z0&?l-=x8H?+}-`ck%^*R;scnDz5wDg(}Dm%rGP@C1Htimp#pCrK=}_;=>UM02mk>n z-+$vj8ld^V+8Y0k{9oEJ@D0#ijGq${blPK(zxt&5N8s;lV88zcJY1l)TixYliHh5FSpTPY8whUt_luv3&XKpRzX*UVguG0RHFz@4t15 zGzZ%VeJ5{(mn$U(QjGHOb^{ewf7E{@6-E4I=STRtQv4K9hQt)$=Oy%)4!P-%arOdr zPk)U6--|(su|xSNQ3|Di(*6q&fR&<9mI;K4t3QG=V1OzjBH|1fMh(i78k`gp6o%Ao--5VPK literal 0 HcmV?d00001