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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
//! File Information Block (FIB) parsing.
//!
//! The FIB sits at offset 0 of the `WordDocument` stream and is the index into
//! everything else. Rather than hard-coding the well-known Word-97 byte offsets
//! we *navigate* the variable-length sub-structures (FibBase → csw·FibRgW97 →
//! cslw·FibRgLw97 → cbRgFcLcb·FibRgFcLcb) so non-standard writers are handled.
//!
//! Reference: [MS-DOC] 2.5.1 (Fib), 2.5.2–2.5.6 (FibRg*).
use crate::error::{Error, Result};
use crate::util::{u16le, u32le};
/// Parsed FIB fields needed for text extraction.
#[derive(Debug, Clone)]
pub(crate) struct Fib {
/// `nFib` — format version. `< 0x00C1` is pre-Word-97 (Word 6/95).
pub nfib: u16,
/// `lid` — document language id; implies the ANSI codepage of compressed
/// (8-bit) pieces (e.g. Korean `0x0412` → cp949).
pub lid: u16,
/// `fEncrypted` — the document is encrypted or XOR-obfuscated.
pub encrypted: bool,
/// `fObfuscated` — XOR obfuscation ([MS-DOC] 2.2.6.1) vs. real encryption.
pub obfuscated: bool,
/// `fComplex` — document uses a complex (multi-piece) piece table.
pub complex: bool,
/// `fWhichTblStm` — false → `0Table`, true → `1Table`.
pub which_table_stream_one: bool,
/// Byte offset of the CLX within the table stream.
pub fc_clx: usize,
/// Byte length of the CLX.
pub lcb_clx: usize,
/// `fcPlcfBtePapx`/`lcbPlcfBtePapx` — the bin table locating the PAPX FKPs
/// (paragraph properties), used for table/list structure.
pub fc_plcf_bte_papx: usize,
pub lcb_plcf_bte_papx: usize,
/// `fcPlcfBteChpx`/`lcbPlcfBteChpx` — the bin table locating the CHPX FKPs
/// (character properties: bold/italic/font/…), for the rich document model.
pub fc_plcf_bte_chpx: usize,
pub lcb_plcf_bte_chpx: usize,
/// `fcStshf`/`lcbStshf` — the style sheet (STSH), for paragraph style names
/// and heading-level resolution.
pub fc_stshf: usize,
pub lcb_stshf: usize,
/// `fcSttbfFfn`/`lcbSttbfFfn` — the font-name table (`SttbfFfn`), resolving a
/// CHPX font index (`sprmCRgFtc0`) to a family name.
pub fc_sttbf_ffn: usize,
pub lcb_sttbf_ffn: usize,
/// `fcPlfLst`/`lcbPlfLst` — the list-definition table (`PlfLst`).
pub fc_plf_lst: usize,
pub lcb_plf_lst: usize,
/// `fcPlfLfo`/`lcbPlfLfo` — the list-format-override table (`PlfLfo`).
pub fc_plf_lfo: usize,
pub lcb_plf_lfo: usize,
/// `fcPlcfHdd`/`lcbPlcfHdd` — story boundaries for the header document.
pub fc_plcf_hdd: usize,
pub lcb_plcf_hdd: usize,
/// `fcPlcfandRef`/`lcbPlcfandRef` — annotation reference records.
pub fc_plcfand_ref: usize,
pub lcb_plcfand_ref: usize,
/// `fcGrpXstAtnOwners`/`lcbGrpXstAtnOwners` — annotation author names.
pub fc_grp_xst_atn_owners: usize,
pub lcb_grp_xst_atn_owners: usize,
/// Character counts partitioning the CP space across sub-documents.
pub ccp_text: u32,
pub ccp_ftn: u32,
pub ccp_hdd: u32,
pub ccp_atn: u32,
pub ccp_edn: u32,
pub ccp_txbx: u32,
}
impl Fib {
pub(crate) fn parse(word: &[u8]) -> Result<Fib> {
// FibBase.wIdent must be 0xA5EC.
if u16le(word, 0).ok_or(Error::Fib("truncated header"))? != 0xA5EC {
return Err(Error::Fib("bad wIdent (not 0xA5EC)"));
}
let nfib = u16le(word, 2).ok_or(Error::Fib("truncated nFib"))?;
let lid = u16le(word, 0x14).unwrap_or(0);
// FibBase flags word at 0x0A: bit 2 = fComplex, bit 8 = fEncrypted,
// bit 9 = fWhichTblStm, bit 15 = fObfuscated.
let flags = u16le(word, 0x0A).ok_or(Error::Fib("truncated flags"))?;
let complex = (flags & 0x0004) != 0;
let encrypted = (flags & 0x0100) != 0;
let which_table_stream_one = (flags & 0x0200) != 0;
let obfuscated = (flags & 0x8000) != 0;
// Navigate the variable-length FIB layout.
// FibBase is 32 bytes; then csw:u16, FibRgW97[csw], cslw:u16,
// FibRgLw97[cslw], cbRgFcLcb:u16, FibRgFcLcb[...].
let csw = u16le(word, 32).ok_or(Error::Fib("missing csw"))? as usize;
let rglw_count_off = 34 + csw * 2; // cslw position
let cslw = u16le(word, rglw_count_off).ok_or(Error::Fib("missing cslw"))? as usize;
let rglw_off = rglw_count_off + 2; // FibRgLw97 start
let fclcb_count_off = rglw_off + cslw * 4; // cbRgFcLcb position
let fclcb_off = fclcb_count_off + 2; // FibRgFcLcb start
// FibRgLw97: ccpText is field index 3, ccpFtn 4, ccpHdd 5, ccpAtn 7,
// ccpEdn 8, ccpTxbx 9 (each u32). (index 6 is ccpMcr, unused here.)
let lw = |i: usize| u32le(word, rglw_off + i * 4).unwrap_or(0);
let ccp_text = lw(3);
let ccp_ftn = lw(4);
let ccp_hdd = lw(5);
let ccp_atn = lw(7);
let ccp_edn = lw(8);
let ccp_txbx = lw(9);
// FibRgFcLcb97: fcClx is fc/lcb pair index 33.
let fc_clx = u32le(word, fclcb_off + 33 * 8).ok_or(Error::Fib("missing fcClx"))? as usize;
let lcb_clx =
u32le(word, fclcb_off + 33 * 8 + 4).ok_or(Error::Fib("missing lcbClx"))? as usize;
// fcPlcfBtePapx is fc/lcb pair index 13 (paragraph-property bin table).
let fc_plcf_bte_papx = u32le(word, fclcb_off + 13 * 8).unwrap_or(0) as usize;
let lcb_plcf_bte_papx = u32le(word, fclcb_off + 13 * 8 + 4).unwrap_or(0) as usize;
// fcPlcfBteChpx is pair index 12 (character-property bin table); fcStshf
// is pair index 2 (style sheet).
let fc_plcf_bte_chpx = u32le(word, fclcb_off + 12 * 8).unwrap_or(0) as usize;
let lcb_plcf_bte_chpx = u32le(word, fclcb_off + 12 * 8 + 4).unwrap_or(0) as usize;
// STSH is fc/lcb pair index 1 (pair 0 = fcStshfOrig, the stale fast-save
// backup stylesheet — must not be used for istd→name).
let fc_stshf = u32le(word, fclcb_off + 8).unwrap_or(0) as usize;
let lcb_stshf = u32le(word, fclcb_off + 8 + 4).unwrap_or(0) as usize;
// SttbfFfn = FibRgFcLcb97 index 15 (fc at +15*8, lcb at +15*8+4).
let fc_sttbf_ffn = u32le(word, fclcb_off + 15 * 8).unwrap_or(0) as usize;
let lcb_sttbf_ffn = u32le(word, fclcb_off + 15 * 8 + 4).unwrap_or(0) as usize;
// fcPlfLst is pair index 73, fcPlfLfo index 74 (list tables).
let fc_plf_lst = u32le(word, fclcb_off + 73 * 8).unwrap_or(0) as usize;
let lcb_plf_lst = u32le(word, fclcb_off + 73 * 8 + 4).unwrap_or(0) as usize;
let fc_plf_lfo = u32le(word, fclcb_off + 74 * 8).unwrap_or(0) as usize;
let lcb_plf_lfo = u32le(word, fclcb_off + 74 * 8 + 4).unwrap_or(0) as usize;
// PlcfHdd = FibRgFcLcb97 index 11.
let fc_plcf_hdd = u32le(word, fclcb_off + 11 * 8).unwrap_or(0) as usize;
let lcb_plcf_hdd = u32le(word, fclcb_off + 11 * 8 + 4).unwrap_or(0) as usize;
// PlcfandRef = FibRgFcLcb97 index 4; GrpXstAtnOwners = index 36.
let fc_plcfand_ref = u32le(word, fclcb_off + 4 * 8).unwrap_or(0) as usize;
let lcb_plcfand_ref = u32le(word, fclcb_off + 4 * 8 + 4).unwrap_or(0) as usize;
let fc_grp_xst_atn_owners = u32le(word, fclcb_off + 36 * 8).unwrap_or(0) as usize;
let lcb_grp_xst_atn_owners = u32le(word, fclcb_off + 36 * 8 + 4).unwrap_or(0) as usize;
Ok(Fib {
nfib,
lid,
encrypted,
obfuscated,
complex,
which_table_stream_one,
fc_clx,
lcb_clx,
fc_plcf_bte_papx,
lcb_plcf_bte_papx,
fc_plcf_bte_chpx,
lcb_plcf_bte_chpx,
fc_stshf,
lcb_stshf,
fc_sttbf_ffn,
lcb_sttbf_ffn,
fc_plf_lst,
lcb_plf_lst,
fc_plf_lfo,
lcb_plf_lfo,
fc_plcf_hdd,
lcb_plcf_hdd,
fc_plcfand_ref,
lcb_plcfand_ref,
fc_grp_xst_atn_owners,
lcb_grp_xst_atn_owners,
ccp_text,
ccp_ftn,
ccp_hdd,
ccp_atn,
ccp_edn,
ccp_txbx,
})
}
/// The ANSI codepage implied by the document language (`lid`), used to
/// decode compressed (8-bit) pieces. Mirrors LibreOffice ww8
/// `GetCharSetFromLanguage`: Korean `0x0412` → 949, etc.
pub(crate) fn ansi_codepage(&self) -> u16 {
match self.lid & 0x03FF {
0x12 => 949, // Korean (cp949/UHC)
0x11 => 932, // Japanese
0x04 if matches!(self.lid, 0x0404 | 0x0C04 | 0x1404) => 950, // Chinese (Traditional)
0x04 => 936, // Chinese (Simplified)
0x19 | 0x02 | 0x22 | 0x23 | 0x1A => 1251, // Cyrillic
0x08 => 1253, // Greek
0x1F => 1254, // Turkish
0x0D => 1255, // Hebrew
0x01 => 1256, // Arabic
0x1E => 874, // Thai
0x2A => 1258, // Vietnamese
_ => 1252, // Western (default)
}
}
/// Total characters across all sub-documents (the full CP space). The six `ccp_*` fields
/// are unbounded attacker-controlled `u32`s read straight from the FIB, so sum them in
/// `usize` with saturating arithmetic — a plain `u32 + u32` panics (overflow-checks) /
/// wraps on a crafted `.doc`.
pub(crate) fn total_cp(&self) -> usize {
[
self.ccp_text,
self.ccp_ftn,
self.ccp_hdd,
self.ccp_atn,
self.ccp_edn,
self.ccp_txbx,
]
.into_iter()
.fold(0usize, |acc, c| acc.saturating_add(c as usize))
}
/// Name of the table stream holding the CLX.
pub(crate) fn table_stream(&self) -> &'static str {
if self.which_table_stream_one {
"1Table"
} else {
"0Table"
}
}
}
/// Read one `FibRgFcLcb97` fc/lcb pair by index from the raw FIB.
pub(crate) fn fc_lcb_pair(word: &[u8], index: usize) -> Option<(usize, usize)> {
let fclcb_off = fclcb_offset(word)?;
let pair_off = fclcb_off.checked_add(index.checked_mul(8)?)?;
let fc = u32le(word, pair_off)? as usize;
let lcb = u32le(word, pair_off.checked_add(4)?)? as usize;
Some((fc, lcb))
}
fn fclcb_offset(word: &[u8]) -> Option<usize> {
if u16le(word, 0)? != 0xA5EC {
return None;
}
let csw = u16le(word, 32)? as usize;
let rglw_count_off = 34usize.checked_add(csw.checked_mul(2)?)?;
let cslw = u16le(word, rglw_count_off)? as usize;
let rglw_off = rglw_count_off.checked_add(2)?;
let fclcb_count_off = rglw_off.checked_add(cslw.checked_mul(4)?)?;
fclcb_count_off.checked_add(2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn total_cp_saturates_on_overflowing_ccp_fields() {
// A crafted .doc can set every ccp_* field to u32::MAX; their sum overflows u32.
// total_cp() must add in usize (no panic under overflow-checks, no wrap), not `u32 + u32`.
let mut w = vec![0u8; 512];
w[0..2].copy_from_slice(&0xA5ECu16.to_le_bytes()); // wIdent
w[2..4].copy_from_slice(&0x00C1u16.to_le_bytes()); // nFib = Word 97
w[34..36].copy_from_slice(&10u16.to_le_bytes()); // cslw=10 -> FibRgLw97 at off 36
let rglw = 36;
for idx in [3usize, 4, 5, 7, 8, 9] {
// ccpText/Ftn/Hdd/Atn/Edn/Txbx
w[rglw + idx * 4..rglw + idx * 4 + 4].copy_from_slice(&u32::MAX.to_le_bytes());
}
let fib = Fib::parse(&w).expect("minimal FIB should parse");
assert_eq!(fib.ccp_text, u32::MAX);
// Computed in usize: 6 * u32::MAX (fits usize on 64-bit) — the point is no panic/wrap.
assert_eq!(fib.total_cp(), (u32::MAX as usize) * 6);
}
}