253 lines
11 KiB
Zig
253 lines
11 KiB
Zig
//! Web-layer audit: assert whether a built executable carries the
|
|
//! embedded web layer, on the evidence each format actually leaves.
|
|
//!
|
|
//! PE (Windows): the host loads WebView2Loader.dll through LoadLibraryW,
|
|
//! so the loader never appears in the import table — the honest evidence
|
|
//! is the string literal itself, stored as UTF-16 in the web build and
|
|
//! compiled out entirely (with the whole layer) in a native-only build.
|
|
//!
|
|
//! ELF (Linux): the host links webkitgtk-6.0 directly, so the honest
|
|
//! evidence is structural — a libwebkitgtk/libjavascriptcoregtk
|
|
//! DT_NEEDED entry and webkit_*/jsc_* undefined dynamic symbols, all of
|
|
//! which the WebKitGTK compile seam (NATIVE_SDK_ALLOW_WEBKITGTK_STUB)
|
|
//! removes from a native-only build. Both tables are parsed by hand from
|
|
//! the section headers (the same dependency-free spirit as the PE sniff;
|
|
//! package.zig `elfReferencesWebKitGtk` is the package-time twin).
|
|
//!
|
|
//! The audit verifies the container format first so a wrong path can
|
|
//! never "pass" by scanning the wrong kind of file.
|
|
//!
|
|
//! usage: audit_web_layer <exe> present|absent
|
|
|
|
const std = @import("std");
|
|
|
|
const needle_ascii = "WebView2Loader.dll";
|
|
|
|
pub fn main(init: std.process.Init) !void {
|
|
const allocator = init.arena.allocator();
|
|
const args = try init.minimal.args.toSlice(allocator);
|
|
if (args.len != 3) {
|
|
std.debug.print("usage: audit_web_layer <exe> present|absent\n", .{});
|
|
std.process.exit(2);
|
|
}
|
|
const path = args[1];
|
|
const expect_present = if (std.mem.eql(u8, args[2], "present"))
|
|
true
|
|
else if (std.mem.eql(u8, args[2], "absent"))
|
|
false
|
|
else {
|
|
std.debug.print("usage: audit_web_layer <exe> present|absent\n", .{});
|
|
std.process.exit(2);
|
|
};
|
|
|
|
const bytes = std.Io.Dir.cwd().readFileAlloc(init.io, path, allocator, .limited(512 * 1024 * 1024)) catch |err| {
|
|
std.debug.print("failed to read {s}: {s}\n", .{ path, @errorName(err) });
|
|
std.process.exit(1);
|
|
};
|
|
|
|
if (isPeExecutable(bytes)) {
|
|
auditPe(path, bytes, expect_present);
|
|
return;
|
|
}
|
|
if (isElfExecutable(bytes)) {
|
|
auditElf(path, bytes, expect_present);
|
|
return;
|
|
}
|
|
std.debug.print("{s} is not a PE or ELF executable - the audit refuses to scan it\n", .{path});
|
|
std.process.exit(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PE: the WebView2Loader.dll string probe.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn auditPe(path: []const u8, bytes: []const u8, expect_present: bool) void {
|
|
// The literal lives as a wide string (L"WebView2Loader.dll"), so the
|
|
// authoritative probe is UTF-16LE; ASCII is scanned too in case a
|
|
// future host stores it narrow.
|
|
var needle_wide: [needle_ascii.len * 2]u8 = undefined;
|
|
for (needle_ascii, 0..) |ch, index| {
|
|
needle_wide[index * 2] = ch;
|
|
needle_wide[index * 2 + 1] = 0;
|
|
}
|
|
const found = std.mem.indexOf(u8, bytes, &needle_wide) != null or
|
|
std.mem.indexOf(u8, bytes, needle_ascii) != null;
|
|
|
|
if (found == expect_present) {
|
|
std.debug.print("web-layer audit ok: {s} {s} {s}\n", .{ path, if (found) "references" else "does not reference", needle_ascii });
|
|
return;
|
|
}
|
|
if (expect_present) {
|
|
std.debug.print("web-layer audit FAILED: {s} does not reference {s} but this app declares web use - the embedded WebView layer was compiled out of a web build\n", .{ path, needle_ascii });
|
|
} else {
|
|
std.debug.print("web-layer audit FAILED: {s} references {s} but nothing in its app.zon declares web use - the native-only inference did not strip the web layer\n", .{ path, needle_ascii });
|
|
}
|
|
std.process.exit(1);
|
|
}
|
|
|
|
/// The same PE-header sniff packaging uses to pick the loader
|
|
/// architecture (package.zig peExecutableIsArm64): MZ magic, then the
|
|
/// PE\0\0 signature at the offset the DOS header names.
|
|
fn isPeExecutable(bytes: []const u8) bool {
|
|
if (bytes.len < 0x40 or bytes[0] != 'M' or bytes[1] != 'Z') return false;
|
|
const pe_offset: usize = std.mem.readInt(u32, bytes[0x3c..0x40], .little);
|
|
if (pe_offset + 4 > bytes.len) return false;
|
|
return std.mem.eql(u8, bytes[pe_offset..][0..4], "PE\x00\x00");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ELF: DT_NEEDED entries plus dynamic symbol names.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn auditElf(path: []const u8, bytes: []const u8, expect_present: bool) void {
|
|
const reference = scanElfWebKitReference(bytes) catch |err| {
|
|
std.debug.print("{s} could not be scanned as ELF ({s}) - the audit refuses to guess\n", .{ path, @errorName(err) });
|
|
std.process.exit(1);
|
|
};
|
|
|
|
if ((reference != null) == expect_present) {
|
|
if (reference) |found| {
|
|
std.debug.print("web-layer audit ok: {s} references WebKitGTK ({s})\n", .{ path, found });
|
|
} else {
|
|
std.debug.print("web-layer audit ok: {s} references no WebKitGTK library or symbol\n", .{path});
|
|
}
|
|
return;
|
|
}
|
|
if (expect_present) {
|
|
std.debug.print("web-layer audit FAILED: {s} carries no WebKitGTK reference but this app declares web use - the embedded web layer was compiled out of a web build\n", .{path});
|
|
} else {
|
|
std.debug.print("web-layer audit FAILED: {s} references WebKitGTK ({s}) but nothing in its app.zon declares web use - the native-only inference did not strip the web layer\n", .{ path, reference.? });
|
|
}
|
|
std.process.exit(1);
|
|
}
|
|
|
|
const ElfError = error{
|
|
NotElf64LittleEndian,
|
|
NoSectionHeaders,
|
|
TruncatedSectionHeaders,
|
|
TruncatedSection,
|
|
};
|
|
|
|
fn isElfExecutable(bytes: []const u8) bool {
|
|
return bytes.len >= 4 and std.mem.eql(u8, bytes[0..4], "\x7fELF");
|
|
}
|
|
|
|
const sht_dynamic: u32 = 6;
|
|
const sht_dynsym: u32 = 11;
|
|
const dt_needed: i64 = 1;
|
|
|
|
const ElfSection = struct {
|
|
sh_type: u32,
|
|
link: u32,
|
|
offset: u64,
|
|
size: u64,
|
|
entsize: u64,
|
|
};
|
|
|
|
/// The first WebKitGTK reference in an ELF executable, or null: a
|
|
/// DT_NEEDED entry naming libwebkitgtk/libjavascriptcoregtk, or a
|
|
/// dynamic-symbol name in webkit_*/jsc_* (the WebKitGTK and JSC C
|
|
/// namespaces the GTK host calls into). Returns the matched name so a
|
|
/// failed audit teaches what leaked. Parsing walks the section headers
|
|
/// (zig-built executables always carry them); a stripped section table
|
|
/// proves nothing about the layer, so it is refused rather than passed.
|
|
fn scanElfWebKitReference(bytes: []const u8) ElfError!?[]const u8 {
|
|
if (!isElfExecutable(bytes) or bytes.len < 0x40) return error.NotElf64LittleEndian;
|
|
// EI_CLASS 2 = 64-bit, EI_DATA 1 = little endian: every Linux target
|
|
// this toolkit builds. Anything else is refused, never guessed at.
|
|
if (bytes[4] != 2 or bytes[5] != 1) return error.NotElf64LittleEndian;
|
|
|
|
const sh_offset: u64 = std.mem.readInt(u64, bytes[0x28..0x30], .little);
|
|
const sh_entsize: u16 = std.mem.readInt(u16, bytes[0x3a..0x3c], .little);
|
|
const sh_count: u16 = std.mem.readInt(u16, bytes[0x3c..0x3e], .little);
|
|
if (sh_offset == 0 or sh_count == 0) return error.NoSectionHeaders;
|
|
if (sh_entsize < 0x40) return error.TruncatedSectionHeaders;
|
|
|
|
var index: u16 = 0;
|
|
while (index < sh_count) : (index += 1) {
|
|
const header = try elfSlice(bytes, sh_offset + @as(u64, index) * sh_entsize, 0x40);
|
|
const section = ElfSection{
|
|
.sh_type = std.mem.readInt(u32, header[0x04..0x08], .little),
|
|
.link = std.mem.readInt(u32, header[0x28..0x2c], .little),
|
|
.offset = std.mem.readInt(u64, header[0x18..0x20], .little),
|
|
.size = std.mem.readInt(u64, header[0x20..0x28], .little),
|
|
.entsize = std.mem.readInt(u64, header[0x38..0x40], .little),
|
|
};
|
|
switch (section.sh_type) {
|
|
sht_dynamic => {
|
|
if (try scanDynamicNeeded(bytes, section, sh_offset, sh_entsize, sh_count)) |name| return name;
|
|
},
|
|
sht_dynsym => {
|
|
if (try scanDynamicSymbols(bytes, section, sh_offset, sh_entsize, sh_count)) |name| return name;
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// DT_NEEDED entries of a .dynamic section, matched against the WebKit
|
|
/// library names.
|
|
fn scanDynamicNeeded(bytes: []const u8, section: ElfSection, sh_offset: u64, sh_entsize: u16, sh_count: u16) ElfError!?[]const u8 {
|
|
const strtab = try elfLinkedStringTable(bytes, section.link, sh_offset, sh_entsize, sh_count);
|
|
const entries = try elfSlice(bytes, section.offset, section.size);
|
|
var cursor: usize = 0;
|
|
while (cursor + 16 <= entries.len) : (cursor += 16) {
|
|
const tag: i64 = @bitCast(std.mem.readInt(u64, entries[cursor..][0..8], .little));
|
|
if (tag != dt_needed) continue;
|
|
const name_offset: u64 = std.mem.readInt(u64, entries[cursor + 8 ..][0..8], .little);
|
|
const name = elfString(strtab, name_offset) orelse continue;
|
|
if (nameIsWebKitLibrary(name)) return name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Dynamic-symbol names of a .dynsym section, matched against the
|
|
/// WebKitGTK C namespaces.
|
|
fn scanDynamicSymbols(bytes: []const u8, section: ElfSection, sh_offset: u64, sh_entsize: u16, sh_count: u16) ElfError!?[]const u8 {
|
|
const strtab = try elfLinkedStringTable(bytes, section.link, sh_offset, sh_entsize, sh_count);
|
|
const entsize: usize = if (section.entsize >= 24) @intCast(section.entsize) else 24;
|
|
const symbols = try elfSlice(bytes, section.offset, section.size);
|
|
var cursor: usize = 0;
|
|
while (cursor + 24 <= symbols.len) : (cursor += entsize) {
|
|
const name_offset: u32 = std.mem.readInt(u32, symbols[cursor..][0..4], .little);
|
|
if (name_offset == 0) continue;
|
|
const name = elfString(strtab, name_offset) orelse continue;
|
|
if (nameIsWebKitSymbol(name)) return name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn nameIsWebKitLibrary(name: []const u8) bool {
|
|
return std.mem.indexOf(u8, name, "webkitgtk") != null or
|
|
std.mem.indexOf(u8, name, "javascriptcoregtk") != null;
|
|
}
|
|
|
|
fn nameIsWebKitSymbol(name: []const u8) bool {
|
|
return std.mem.startsWith(u8, name, "webkit_") or
|
|
std.mem.startsWith(u8, name, "jsc_");
|
|
}
|
|
|
|
/// The string table a section's sh_link points at.
|
|
fn elfLinkedStringTable(bytes: []const u8, link: u32, sh_offset: u64, sh_entsize: u16, sh_count: u16) ElfError![]const u8 {
|
|
if (link >= sh_count) return error.TruncatedSectionHeaders;
|
|
const header = try elfSlice(bytes, sh_offset + @as(u64, link) * sh_entsize, 0x40);
|
|
const offset = std.mem.readInt(u64, header[0x18..0x20], .little);
|
|
const size = std.mem.readInt(u64, header[0x20..0x28], .little);
|
|
return elfSlice(bytes, offset, size);
|
|
}
|
|
|
|
fn elfString(strtab: []const u8, offset: u64) ?[]const u8 {
|
|
if (offset >= strtab.len) return null;
|
|
const start: usize = @intCast(offset);
|
|
const end = std.mem.indexOfScalarPos(u8, strtab, start, 0) orelse return null;
|
|
return strtab[start..end];
|
|
}
|
|
|
|
fn elfSlice(bytes: []const u8, offset: u64, size: u64) ElfError![]const u8 {
|
|
if (offset > bytes.len) return error.TruncatedSection;
|
|
const start: usize = @intCast(offset);
|
|
if (size > bytes.len - start) return error.TruncatedSection;
|
|
return bytes[start .. start + @as(usize, @intCast(size))];
|
|
}
|