Skip to content

Commit 082d426

Browse files
committed
libclamav: recover HWP3 attachments after paragraph errors
1 parent 16cb181 commit 082d426

2 files changed

Lines changed: 216 additions & 15 deletions

File tree

libclamav/hwp.c

Lines changed: 138 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,11 @@ struct hwp3_docsummary_entry {
482482

483483
#define HWP3_FIELD_LENGTH 512
484484

485+
/* Additional information block #1 payload prefixes. */
486+
#define HWP3_IMAGE_INFO_SIZE 32 /* 16-byte name + 16-byte image format. */
487+
#define HWP3_BACKGROUND_IMAGE_INFO_SIZE 324 /* Fixed background-image fields before the image bytes. */
488+
#define HWP3_OLE2_MIN_SIZE 8 /* Size of an OLE2 compound-file signature. */
489+
485490
#define PI_PPFS 0 /* offset 0 (1 byte) - prior paragraph format style */
486491
#define PI_NCHARS 1 /* offset 1 (2 bytes) - character count */
487492
#define PI_NLINES 3 /* offset 3 (2 bytes) - line count */
@@ -1601,6 +1606,16 @@ static inline cl_error_t parsehwp3_infoblk_1(cli_ctx *ctx, fmap_t *dmap, size_t
16011606
case 1: /* Image Data */
16021607
hwp3_debug("HWP3.x: Information Block[%llu]: TYPE: Image Data\n", infoloc);
16031608

1609+
/*
1610+
* The image bytes follow two fixed 16-byte fields: name and format.
1611+
* A shorter block cannot contain image data and subtracting the
1612+
* prefix size from its unsigned length would underflow.
1613+
*/
1614+
if (infolen < HWP3_IMAGE_INFO_SIZE) {
1615+
cli_errmsg("HWP3.x: Information Block[%llu]: Image Data block is too short: %u\n", infoloc, infolen);
1616+
return CL_EFORMAT;
1617+
}
1618+
16041619
if (SCAN_COLLECT_METADATA)
16051620
cli_jsonstr(entry, "Type", "Image Data");
16061621

@@ -1619,10 +1634,9 @@ static inline cl_error_t parsehwp3_infoblk_1(cli_ctx *ctx, fmap_t *dmap, size_t
16191634
}
16201635
hwp3_debug("HWP3.x: Information Block[%llu]: FORM: %s\n", infoloc, field);
16211636
#endif
1622-
/* 32 bytes for extra data fields */
1623-
if (infolen > 0)
1624-
ret = cli_magic_scan_nested_fmap_type(map, *offset + 32, infolen - 32, ctx,
1625-
CL_TYPE_ANY, NULL, LAYER_ATTRIBUTES_NONE);
1637+
/* Scan only the image bytes after the fixed metadata fields. */
1638+
ret = cli_magic_scan_nested_fmap_type(map, *offset + HWP3_IMAGE_INFO_SIZE, infolen - HWP3_IMAGE_INFO_SIZE, ctx,
1639+
CL_TYPE_ANY, NULL, LAYER_ATTRIBUTES_NONE);
16261640
break;
16271641
case 2: /* OLE2 Data */
16281642
hwp3_debug("HWP3.x: Information Block[%llu]: TYPE: OLE2 Data\n", infoloc);
@@ -1682,9 +1696,19 @@ static inline cl_error_t parsehwp3_infoblk_1(cli_ctx *ctx, fmap_t *dmap, size_t
16821696
case 6: /* Background Image Data */
16831697
hwp3_debug("HWP3.x: Information Block[%llu]: TYPE: Background Image Data\n", infoloc);
16841698

1699+
/*
1700+
* A background image has 324 bytes of fixed fields before its image.
1701+
* A shorter block cannot contain image data and subtracting the
1702+
* prefix size from its unsigned length would underflow.
1703+
*/
1704+
if (infolen < HWP3_BACKGROUND_IMAGE_INFO_SIZE) {
1705+
cli_errmsg("HWP3.x: Information Block[%llu]: Background Image Data block is too short: %u\n", infoloc, infolen);
1706+
return CL_EFORMAT;
1707+
}
1708+
16851709
if (SCAN_COLLECT_METADATA) {
16861710
cli_jsonstr(entry, "Type", "Background Image Data");
1687-
cli_jsonint(entry, "ImageSize", infolen - 324);
1711+
cli_jsonint(entry, "ImageSize", infolen - HWP3_BACKGROUND_IMAGE_INFO_SIZE);
16881712
}
16891713

16901714
#if HWP3_DEBUG /* additional fields can be added */
@@ -1695,10 +1719,10 @@ static inline cl_error_t parsehwp3_infoblk_1(cli_ctx *ctx, fmap_t *dmap, size_t
16951719
}
16961720
hwp3_debug("HWP3.x: Information Block[%llu]: NAME: %s\n", infoloc, field);
16971721
#endif
1698-
/* 324 bytes for extra data fields */
1699-
if (infolen > 0)
1700-
ret = cli_magic_scan_nested_fmap_type(map, *offset + 324, infolen - 324, ctx,
1701-
CL_TYPE_ANY, NULL, LAYER_ATTRIBUTES_NONE);
1722+
/* Scan only the image bytes after the fixed background-image fields. */
1723+
ret = cli_magic_scan_nested_fmap_type(map, *offset + HWP3_BACKGROUND_IMAGE_INFO_SIZE,
1724+
infolen - HWP3_BACKGROUND_IMAGE_INFO_SIZE, ctx,
1725+
CL_TYPE_ANY, NULL, LAYER_ATTRIBUTES_NONE);
17021726
break;
17031727
case 0x100: /* Table Extension */
17041728
hwp3_debug("HWP3.x: Information Block[%llu]: TYPE: Table Extension\n", infoloc);
@@ -1727,12 +1751,97 @@ static inline cl_error_t parsehwp3_infoblk_1(cli_ctx *ctx, fmap_t *dmap, size_t
17271751
return ret;
17281752
}
17291753

1754+
static bool hwp3_infoblk_1_header_is_plausible(fmap_t *map, size_t offset)
1755+
{
1756+
uint32_t infoid, infolen;
1757+
1758+
if (fmap_readn(map, &infoid, offset, sizeof(infoid)) != sizeof(infoid))
1759+
return false;
1760+
1761+
infoid = le32_to_host(infoid);
1762+
/* Booking Information is the only block consisting solely of its 4-byte ID. */
1763+
if (infoid == 5)
1764+
return true;
1765+
1766+
/* Recovery accepts only information block types defined by the HWP3 parser. */
1767+
if ((infoid > 6) && (infoid != 0x100) && (infoid != 0x101))
1768+
return false;
1769+
1770+
if (fmap_readn(map, &infolen, offset + sizeof(infoid), sizeof(infolen)) != sizeof(infolen))
1771+
return false;
1772+
1773+
infolen = le32_to_host(infolen);
1774+
if ((infoid == 0) && (infolen != 0))
1775+
return false;
1776+
1777+
return infolen <= map->len - offset - sizeof(infoid) - sizeof(infolen);
1778+
}
1779+
1780+
static bool findhwp3_infoblk_1(fmap_t *map, size_t start, size_t *found)
1781+
{
1782+
uint32_t infoid, infolen;
1783+
size_t offset, payload, next;
1784+
1785+
if ((start >= map->len) || (map->len - start < 12))
1786+
return false;
1787+
1788+
/*
1789+
* A paragraph error leaves its caller's offset at the start of the malformed
1790+
* paragraph, so the information-block boundary is no longer known. Search
1791+
* byte-by-byte because variable-length HWP3 paragraphs are not guaranteed to
1792+
* leave the following section on a fixed alignment.
1793+
*/
1794+
for (offset = start; offset <= map->len - 12; offset++) {
1795+
if (fmap_readn(map, &infoid, offset, sizeof(infoid)) != sizeof(infoid))
1796+
return false;
1797+
1798+
infoid = le32_to_host(infoid);
1799+
if ((infoid != 1) && (infoid != 2) && (infoid != 6))
1800+
continue;
1801+
1802+
if (fmap_readn(map, &infolen, offset + sizeof(infoid), sizeof(infolen)) != sizeof(infolen))
1803+
return false;
1804+
1805+
infolen = le32_to_host(infolen);
1806+
/*
1807+
* Limit resynchronization candidates to blocks that contain nested data.
1808+
* Their fixed prefixes also provide useful minimum lengths that reduce
1809+
* false matches while scanning malformed paragraph bytes.
1810+
*/
1811+
if (((infoid == 1) && (infolen < HWP3_IMAGE_INFO_SIZE)) ||
1812+
((infoid == 2) && (infolen < HWP3_OLE2_MIN_SIZE)) ||
1813+
((infoid == 6) && (infolen < HWP3_BACKGROUND_IMAGE_INFO_SIZE)))
1814+
continue;
1815+
1816+
payload = offset + sizeof(infoid) + sizeof(infolen);
1817+
if (infolen > map->len - payload)
1818+
continue;
1819+
1820+
next = payload + infolen;
1821+
/* Requiring a valid following header further avoids treating paragraph data as a block. */
1822+
if (!hwp3_infoblk_1_header_is_plausible(map, next))
1823+
continue;
1824+
1825+
*found = offset;
1826+
return true;
1827+
}
1828+
1829+
return false;
1830+
}
1831+
1832+
static bool hwp3_parse_error_is_recoverable(cl_error_t ret)
1833+
{
1834+
/* Do not hide resource-limit, allocation, callback, or detection results. */
1835+
return (ret == CL_EREAD) || (ret == CL_EFORMAT) || (ret == CL_EPARSE);
1836+
}
1837+
17301838
static cl_error_t hwp3_cb(void *cbdata, int fd, const char *filepath, cli_ctx *ctx)
17311839
{
17321840
cl_error_t ret = CL_SUCCESS;
17331841
fmap_t *map, *dmap;
17341842
size_t offset, start, new_offset;
17351843
int i, p = 0, last = 0;
1844+
bool paragraph_parse_failed = false;
17361845
uint16_t nstyles;
17371846
json_object *fonts = NULL;
17381847

@@ -1820,9 +1929,20 @@ static cl_error_t hwp3_cb(void *cbdata, int fd, const char *filepath, cli_ctx *c
18201929
while (!last && ((ret = parsehwp3_paragraph(ctx, map, p++, 0, &offset, &last)) == CL_SUCCESS)) continue;
18211930
/* return is never a virus */
18221931
if (ret != CL_SUCCESS) {
1823-
if (dmap)
1824-
fmap_free(dmap);
1825-
return ret;
1932+
if (!hwp3_parse_error_is_recoverable(ret)) {
1933+
if (dmap)
1934+
fmap_free(dmap);
1935+
return ret;
1936+
}
1937+
1938+
paragraph_parse_failed = true;
1939+
cli_warnmsg("HWP3.x: Paragraph parsing failed; attempting to recover additional information blocks\n");
1940+
1941+
if (!findhwp3_infoblk_1(map, offset, &offset)) {
1942+
cli_warnmsg("HWP3.x: Failed to recover additional information blocks; scanning the complete content stream\n");
1943+
offset = map->len;
1944+
}
1945+
ret = CL_SUCCESS;
18261946
}
18271947

18281948
if (SCAN_COLLECT_METADATA)
@@ -1832,9 +1952,12 @@ static cl_error_t hwp3_cb(void *cbdata, int fd, const char *filepath, cli_ctx *c
18321952
/* 'additional information block #1's - attachments and media */
18331953
while (!last && ((ret = parsehwp3_infoblk_1(ctx, map, &offset, &last)) == CL_SUCCESS)) continue;
18341954

1835-
/* scan the uncompressed stream - both compressed and uncompressed cases [ALLMATCH] */
1836-
if (ret == CL_SUCCESS) {
1837-
size_t dlen = offset - start;
1955+
/*
1956+
* Scan the complete content stream after paragraph recovery. This preserves
1957+
* raw-signature coverage even if a later information block is also malformed.
1958+
*/
1959+
if ((ret == CL_SUCCESS) || (paragraph_parse_failed && hwp3_parse_error_is_recoverable(ret))) {
1960+
size_t dlen = paragraph_parse_failed ? map->len - start : offset - start;
18381961

18391962
ret = cli_magic_scan_nested_fmap_type(map, start, dlen, ctx, CL_TYPE_ANY, NULL, LAYER_ATTRIBUTES_NONE);
18401963
}

unit_tests/clamscan/hwp3_test.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright (C) 2026 Cisco Systems, Inc. and/or its affiliates. All rights reserved.
2+
3+
"""
4+
Run clamscan HWP3 tests.
5+
"""
6+
7+
import hashlib
8+
import re
9+
import struct
10+
import sys
11+
from pathlib import Path
12+
13+
sys.path.append(str(Path(__file__).resolve().parents[1]))
14+
import testcase
15+
16+
17+
class TC(testcase.TestCase):
18+
@classmethod
19+
def setUpClass(cls):
20+
super(TC, cls).setUpClass()
21+
22+
@classmethod
23+
def tearDownClass(cls):
24+
super(TC, cls).tearDownClass()
25+
26+
def setUp(self):
27+
super(TC, self).setUp()
28+
29+
def tearDown(self):
30+
super(TC, self).tearDown()
31+
self.verify_valgrind_log()
32+
33+
def test_malformed_paragraph_does_not_skip_attachment(self):
34+
self.step_name('Test malformed HWP3 paragraph does not skip a later attachment')
35+
36+
payload = b'hwp3-attachment-after-malformed-paragraph'
37+
testfile = TC.path_tmp / 'malformed-paragraph.hwp'
38+
path_db = TC.path_tmp / 'hwp3-attachment.hdb'
39+
40+
hwp3 = bytearray(b'HWP Document File V3.00 \x1a\x01\x02\x03\x04\x05')
41+
hwp3.extend(bytes(128)) # Document information.
42+
hwp3.extend(bytes(1008)) # Document summary.
43+
hwp3.extend(bytes(2 * 7)) # Seven empty font tables.
44+
hwp3.extend(bytes(2)) # No styles.
45+
46+
# Long paragraph header with an impossible line count. The parser rejects
47+
# this before reaching the additional information blocks below.
48+
hwp3.extend(struct.pack('<BHHB', 0, 1, 0xffff, 0))
49+
50+
# Additional information block #1: embedded OLE2 data, then terminator.
51+
hwp3.extend(struct.pack('<II', 2, len(payload)))
52+
hwp3.extend(payload)
53+
hwp3.extend(struct.pack('<II', 0, 0))
54+
testfile.write_bytes(hwp3)
55+
56+
path_db.write_text(
57+
'{}:{}:HWP3_RECOVERED_ATTACHMENT\n'.format(
58+
hashlib.sha256(payload).hexdigest(), len(payload)
59+
)
60+
)
61+
62+
command = (
63+
'{valgrind} {valgrind_args} {clamscan} --scan-hwp3=yes '
64+
'-d {path_db} {testfile}'
65+
).format(
66+
valgrind=TC.valgrind,
67+
valgrind_args=TC.valgrind_args,
68+
clamscan=TC.clamscan,
69+
path_db=path_db,
70+
testfile=testfile,
71+
)
72+
output = self.execute_command(command)
73+
74+
assert output.ec == 1
75+
self.verify_output(
76+
output.out,
77+
expected=[re.escape('HWP3_RECOVERED_ATTACHMENT.UNOFFICIAL FOUND')],
78+
)

0 commit comments

Comments
 (0)