Skip to content

Commit 4d0909e

Browse files
authored
Merge pull request #30 from everettjf/claude/review-and-plan-g4p9o
Harden view-node dumping against malformed input
2 parents 5414f54 + a8dae37 commit 4d0909e

3 files changed

Lines changed: 112 additions & 10 deletions

File tree

src/libmoex/node/loadcmd/LoadCommand_SYMTAB.h

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include "libmoex/node/LoadCommand.h"
99
#include "libmoex/node/Common.h"
1010
#include "libmoex/node/MachHeader.h"
11+
#include <cstring>
1112

1213
MOEX_NAMESPACE_BEGIN
1314

@@ -95,9 +96,34 @@ class LoadCommand_LC_SYMTAB : public LoadCommandImpl<qv_symtab_command>{
9596
}
9697

9798
std::string GetStringByStrX(uint32_t strx){
98-
char * stroffset = (char*)GetStringTableOffsetAddress();
99-
std::string name(stroffset + strx);
100-
return name;
99+
char * table = (char*)GetStringTableOffsetAddress();
100+
const uint32_t strsize = cmd_->strsize;
101+
if (strx >= strsize) {
102+
return std::string();
103+
}
104+
const char * start = table + strx;
105+
106+
// Never read past the string table extent or the mapped file: a
107+
// truncated or crafted string table may lack a terminating NUL.
108+
std::size_t max_len = static_cast<std::size_t>(strsize - strx);
109+
auto ctx = header_->ctx();
110+
if (ctx) {
111+
const char * file_start = static_cast<const char*>(ctx->file_start);
112+
const char * file_end = file_start + ctx->file_size;
113+
if (start < file_start || start >= file_end) {
114+
return std::string();
115+
}
116+
const std::size_t max_in_file = static_cast<std::size_t>(file_end - start);
117+
if (max_in_file < max_len) {
118+
max_len = max_in_file;
119+
}
120+
}
121+
122+
const char * nul = static_cast<const char*>(memchr(start, '\0', max_len));
123+
if (nul != nullptr) {
124+
return std::string(start, nul);
125+
}
126+
return std::string(start, start + max_len);
101127
}
102128

103129
public:

src/libmoex/viewnode/ViewNodeDumper.cpp

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,47 @@ static std::string SanitizeCell(const std::string &input) {
5757
return out;
5858
}
5959

60+
// Returns a valid UTF-8 copy of the input, replacing any malformed byte with
61+
// '?'. Cell values can hold raw bytes from malformed/truncated binaries, and
62+
// the bundled nlohmann::json (3.1.2) throws on invalid UTF-8 during dump().
63+
static std::string ToUtf8Safe(const std::string &in) {
64+
std::string out;
65+
out.reserve(in.size());
66+
const std::size_t n = in.size();
67+
std::size_t i = 0;
68+
while (i < n) {
69+
const unsigned char c = static_cast<unsigned char>(in[i]);
70+
if (c < 0x80) {
71+
out += static_cast<char>(c);
72+
++i;
73+
continue;
74+
}
75+
std::size_t len;
76+
uint32_t min_cp;
77+
if ((c & 0xE0) == 0xC0) { len = 2; min_cp = 0x80; }
78+
else if ((c & 0xF0) == 0xE0) { len = 3; min_cp = 0x800; }
79+
else if ((c & 0xF8) == 0xF0) { len = 4; min_cp = 0x10000; }
80+
else { out += '?'; ++i; continue; }
81+
82+
if (i + len > n) { out += '?'; ++i; continue; }
83+
uint32_t cp = c & (0x7Fu >> len);
84+
bool ok = true;
85+
for (std::size_t k = 1; k < len; ++k) {
86+
const unsigned char cc = static_cast<unsigned char>(in[i + k]);
87+
if ((cc & 0xC0) != 0x80) { ok = false; break; }
88+
cp = (cp << 6) | (cc & 0x3Fu);
89+
}
90+
if (!ok || cp < min_cp || cp > 0x10FFFFu || (cp >= 0xD800u && cp <= 0xDFFFu)) {
91+
out += '?';
92+
++i;
93+
continue;
94+
}
95+
out.append(in, i, len);
96+
i += len;
97+
}
98+
return out;
99+
}
100+
60101
static bool NodeHasImmediateContent(ViewNode *node) {
61102
const auto &table = node->table();
62103
const auto &binary = node->binary();
@@ -172,7 +213,7 @@ static Json TableToJson(const TableViewDataPtr &table, const ViewNodeDumpOptions
172213
if (IncludeTableHeaders(options)) {
173214
j["headers"] = Json::array();
174215
for (const auto &header : table->headers) {
175-
j["headers"].push_back(header->data);
216+
j["headers"].push_back(ToUtf8Safe(header->data));
176217
}
177218
}
178219

@@ -194,13 +235,13 @@ static Json TableToJson(const TableViewDataPtr &table, const ViewNodeDumpOptions
194235
r["values"] = Json::array();
195236
r["cells"] = Json::object();
196237
for (const auto &item : row->items) {
197-
r["values"].push_back(item->data);
238+
r["values"].push_back(ToUtf8Safe(item->data));
198239
}
199240
for (size_t col = 0; col < row->items.size(); ++col) {
200241
const std::string key = col < table->headers.size()
201-
? table->headers[col]->data
242+
? ToUtf8Safe(table->headers[col]->data)
202243
: ("column" + std::to_string(col));
203-
r["cells"][key] = row->items[col]->data;
244+
r["cells"][key] = ToUtf8Safe(row->items[col]->data);
204245
}
205246
if (row->size > 0) {
206247
r["byteLength"] = row->size;
@@ -251,9 +292,9 @@ static Json NodeToJson(ViewNode *node,
251292
size_t child_index = 0) {
252293
node->Init();
253294
Json j;
254-
j["name"] = node->GetDisplayName();
295+
j["name"] = ToUtf8Safe(node->GetDisplayName());
255296
j["depth"] = depth;
256-
j["path"] = JoinPath(path_segments);
297+
j["path"] = ToUtf8Safe(JoinPath(path_segments));
257298
j["kind"] = ClassifyNodeKind(node);
258299
j["childIndex"] = child_index;
259300

tests/regression/run_crash_regression.sh

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ if [[ ! -x "${PARSER_BIN}" ]]; then
1010
exit 2
1111
fi
1212

13+
# Optional full app binary. When present we also run the complete view-node
14+
# dumper (--cli) over the malformed corpus, which exercises the rich parsing
15+
# layer (load commands, code signature, ObjC/Swift metadata, dyld info, ...)
16+
# that moex-parse alone does not reach.
17+
APP_BUNDLE_BIN="${ROOT_DIR}/build/MachOExplorer.app/Contents/MacOS/MachOExplorer"
18+
PLAIN_APP_BIN="${ROOT_DIR}/build/MachOExplorer"
19+
APP_BIN=""
20+
if [[ -x "${APP_BUNDLE_BIN}" ]]; then
21+
APP_BIN="${APP_BUNDLE_BIN}"
22+
elif [[ -x "${PLAIN_APP_BIN}" ]]; then
23+
APP_BIN="${PLAIN_APP_BIN}"
24+
fi
25+
1326
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/moex-crash-reg.XXXXXX")"
1427
trap 'rm -rf "${TMP_DIR}"' EXIT
1528

@@ -117,9 +130,31 @@ else
117130
echo "[ok] valid FAT64 parsed successfully: $(basename "${valid_fat64}")"
118131
fi
119132

133+
# Deep pass: run the full view-node dumper over the same corpus so the rich
134+
# parsing layer is also crash-tested against malformed input.
135+
CLI_TOTAL=0
136+
if [[ -n "${APP_BIN}" ]]; then
137+
for f in "${TMP_DIR}"/*; do
138+
for fmt in text json; do
139+
CLI_TOTAL=$((CLI_TOTAL + 1))
140+
set +e
141+
"${APP_BIN}" --cli --format "${fmt}" "${f}" >/dev/null 2>&1
142+
cli_rc=$?
143+
set -e
144+
if [[ "${cli_rc}" -ge 128 ]]; then
145+
echo "[fail] view-node dumper crashed (signal exit=${cli_rc}, format=${fmt}) for: ${f}"
146+
FAIL=1
147+
fi
148+
done
149+
done
150+
echo "[ok] view-node dumper handled corpus safely (runs=${CLI_TOTAL})"
151+
else
152+
echo "[skip] full app binary not built; view-node dumper crash pass skipped"
153+
fi
154+
120155
if [[ "${FAIL}" -ne 0 ]]; then
121156
echo "crash-regression: failed"
122157
exit 1
123158
fi
124159

125-
echo "crash-regression: passed (total=${TOTAL} rejected=${REJECTED} accepted=${ACCEPTED})"
160+
echo "crash-regression: passed (total=${TOTAL} rejected=${REJECTED} accepted=${ACCEPTED} cli-runs=${CLI_TOTAL})"

0 commit comments

Comments
 (0)