From d20ffbb630c130b0ea63f351c74e9ef3b9792ac3 Mon Sep 17 00:00:00 2001 From: momo5502 Date: Wed, 30 Apr 2025 16:52:53 +0200 Subject: [PATCH] Parse PE icons --- page/package-lock.json | 15 ++ page/package.json | 1 + page/src/components/folder.tsx | 33 +++-- page/src/filesystem-explorer.tsx | 38 +++++ page/src/filesystem.ts | 4 + page/src/pe-icon-parser.tsx | 232 +++++++++++++++++++++++++++++++ 6 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 page/src/pe-icon-parser.tsx diff --git a/page/package-lock.json b/page/package-lock.json index 9ebe7d75..68b1b88e 100644 --- a/page/package-lock.json +++ b/page/package-lock.json @@ -28,6 +28,7 @@ "flatbuffers": "^25.2.10", "jszip": "^3.10.1", "lucide-react": "^0.503.0", + "pe-library": "^1.0.1", "react": "^19.0.0", "react-bootstrap-icons": "^1.11.5", "react-dom": "^19.0.0", @@ -4515,6 +4516,20 @@ "node": ">=8" } }, + "node_modules/pe-library": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz", + "integrity": "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==", + "license": "MIT", + "engines": { + "node": ">=14", + "npm": ">=7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/page/package.json b/page/package.json index 2d5911ea..fc0fcad2 100644 --- a/page/package.json +++ b/page/package.json @@ -30,6 +30,7 @@ "flatbuffers": "^25.2.10", "jszip": "^3.10.1", "lucide-react": "^0.503.0", + "pe-library": "^1.0.1", "react": "^19.0.0", "react-bootstrap-icons": "^1.11.5", "react-dom": "^19.0.0", diff --git a/page/src/components/folder.tsx b/page/src/components/folder.tsx index 3c92d8ed..bc1e1a90 100644 --- a/page/src/components/folder.tsx +++ b/page/src/components/folder.tsx @@ -36,9 +36,11 @@ type CreateFolderHandler = () => void; type RemoveElementHandler = (element: FolderElement) => void; type RenameElementHandler = (element: FolderElement) => void; type AddFilesHandler = () => void; +type IconReader = (element: FolderElement) => string | null; export interface FolderProps { elements: FolderElement[]; + iconReader: IconReader; clickHandler: ClickHandler; createFolderHandler: CreateFolderHandler; removeElementHandler: RemoveElementHandler; @@ -54,7 +56,22 @@ function elementComparator(e1: FolderElement, e2: FolderElement) { return e1.name.localeCompare(e2.name); } -function getIcon(element: FolderElement, className: string = "") { +function getIcon( + element: FolderElement, + iconReader: IconReader, + className: string = "", +) { + const icon = iconReader(element); + if (icon) { + return ( +
+
+ +
+
+ ); + } + switch (element.type) { case FolderElementType.File: if (element.name.endsWith(".dll")) { @@ -75,18 +92,18 @@ function getIcon(element: FolderElement, className: string = "") { } } -function renderIcon(element: FolderElement) { +function renderIcon(element: FolderElement, iconReader: IconReader) { let className = "w-6 h-6 flex-1"; - return getIcon(element, className); + return getIcon(element, iconReader, className); } -function renderElement(element: FolderElement, clickHandler: ClickHandler) { +function renderElement(element: FolderElement, props: FolderProps) { return (
clickHandler(element)} + onClick={() => props.clickHandler(element)} className="folder-element select-none flex flex-col gap-2 items-center text-center text-xs p-2 m-2 w-25 h-18 rounded-lg border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50" > - {renderIcon(element)} + {renderIcon(element, props.iconReader)} {element.name} @@ -96,7 +113,7 @@ function renderElement(element: FolderElement, clickHandler: ClickHandler) { function renderElementWithContext(element: FolderElement, props: FolderProps) { if (element.name == "..") { - return renderElement(element, props.clickHandler); + return renderElement(element, props); } return ( @@ -104,7 +121,7 @@ function renderElementWithContext(element: FolderElement, props: FolderProps) { - {renderElement(element, props.clickHandler)} + {renderElement(element, props)}

{element.name}

diff --git a/page/src/filesystem-explorer.tsx b/page/src/filesystem-explorer.tsx index 2a5adf18..8ea92827 100644 --- a/page/src/filesystem-explorer.tsx +++ b/page/src/filesystem-explorer.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/breadcrumb"; import { HouseFill } from "react-bootstrap-icons"; +import { parsePeIcon } from "./pe-icon-parser"; export interface FilesystemExplorerProps { filesystem: Filesystem; @@ -156,6 +157,32 @@ function selectFiles(): Promise { }); } +interface IconCache { + get: (file: string) => string | null; + set: (file: string, data: string | null) => void; +} + +function getPeIcon( + filesystem: Filesystem, + file: string, + cache: Map, +) { + if (!file || !file.endsWith(".exe")) { + return null; + } + + const cachedValue = cache.get(file); + if (cachedValue) { + return cachedValue; + } + + const data = filesystem.readFile(file); + const icon = parsePeIcon(data); + cache.set(file, icon); + + return icon; +} + interface BreadcrumbElement { node: React.ReactNode; targetPath: string[]; @@ -182,6 +209,8 @@ export class FilesystemExplorer extends React.Component< FilesystemExplorerProps, FilesystemExplorerState > { + private iconCache: Map; + constructor(props: FilesystemExplorerProps) { super(props); @@ -189,6 +218,8 @@ export class FilesystemExplorer extends React.Component< this._uploadFiles = this._uploadFiles.bind(this); this._onElementSelect = this._onElementSelect.bind(this); + this.iconCache = new Map(); + this.state = { path: this.props.path, createFolder: false, @@ -556,6 +587,13 @@ export class FilesystemExplorer extends React.Component< this.setState({ renameFile: e.name }) } addFilesHandler={this._onAddFiles} + iconReader={(e) => + getPeIcon( + this.props.filesystem, + makeFullPathWithState(this.state, e.name), + this.iconCache, + ) + } />
)} diff --git a/page/src/filesystem.ts b/page/src/filesystem.ts index 357e3f31..100a7a8f 100644 --- a/page/src/filesystem.ts +++ b/page/src/filesystem.ts @@ -82,6 +82,10 @@ export class Filesystem { this.idbfs.FS.writeFile(file.name, buffer); } + readFile(file: string): Uint8Array { + return this.idbfs.FS.readFile(file); + } + async storeFiles(files: FileWithData[]) { files.forEach((f) => { this._storeFile(f); diff --git a/page/src/pe-icon-parser.tsx b/page/src/pe-icon-parser.tsx new file mode 100644 index 00000000..27a7e6b3 --- /dev/null +++ b/page/src/pe-icon-parser.tsx @@ -0,0 +1,232 @@ +import * as PE from "pe-library"; + +function patchExeFile(exe: PE.NtExecutable) { + // The PE library doesn't support parsing resources if other sections follow + // This might make sense, as the library will have issues rewriting the PE file. + // As we only care about parsing though, just kill the other sections. + const rsrc = exe.getSectionByEntry(PE.Format.ImageDirectoryEntry.Resource); + const orig = exe.getAllSections.bind(exe); + exe.getAllSections = function () { + let x = { skip: false }; + return orig().filter((s) => { + if (x.skip) { + return false; + } + if (s == rsrc) { + x.skip = true; + } + + return true; + }); + }; +} + +function arrayBufferToBase64(bytes: Uint8Array) { + let binary = ""; + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function isPng(buffer: Uint8Array) { + if (buffer.length < 4) { + return false; + } + + return buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71; +} + +function generateDataURL(arrayBuffer: Uint8Array, contentType: string) { + const base64 = arrayBufferToBase64(arrayBuffer); + return `data:${contentType};base64,${base64}`; +} + +interface IconEntry { + width: number; + height: number; + colorCount: number; + reserved: number; + planes: number; + bitCount: number; + bytesInRes: number; + id: number; +} + +interface IconGroup { + reserved: number; + type: number; + icons: IconEntry[]; +} + +function writeUint8(buffer: Uint8Array, offset: number, value: number) { + buffer[offset] = value; +} + +function writeUint16(buffer: Uint8Array, offset: number, value: number) { + writeUint8(buffer, offset + 0, value & 0xff); + writeUint8(buffer, offset + 1, (value >> 8) & 0xff); +} + +function writeUint32(buffer: Uint8Array, offset: number, value: number) { + writeUint16(buffer, offset + 0, value & 0xffff); + writeUint16(buffer, offset + 2, (value >> 16) & 0xffff); +} + +function readUInt8(buffer: Uint8Array, offset: number) { + return buffer[offset]; +} + +function readUInt16(buffer: Uint8Array, offset: number) { + return readUInt8(buffer, offset) | (readUInt8(buffer, offset + 1) << 8); +} + +function readUInt32(buffer: Uint8Array, offset: number) { + return readUInt16(buffer, offset) | (readUInt16(buffer, offset + 2) << 16); +} + +function parseIconGroup(buffer: Uint8Array): IconGroup { + const reserved = readUInt16(buffer, 0); + const type = readUInt16(buffer, 2); + const count = readUInt16(buffer, 4); + + const icons: IconEntry[] = []; + + for (let i = 0; i < count; ++i) { + const start = 6 + i * 14; + const width = readUInt8(buffer, start + 0); + const height = readUInt8(buffer, start + 1); + const colorCount = readUInt8(buffer, start + 2); + const reserved2 = readUInt8(buffer, start + 3); + const planes = readUInt16(buffer, start + 4); + const bitCount = readUInt16(buffer, start + 6); + const bytesInRes = readUInt32(buffer, start + 8); + const id = readUInt16(buffer, start + 12); + + icons.push({ + width, + height, + colorCount, + reserved: reserved2, + planes, + bitCount, + bytesInRes, + id, + }); + } + + return { + reserved, + type, + icons, + }; +} + +function mergeArrayBuffers( + buffer1: ArrayBuffer, + buffer2: ArrayBuffer, +): ArrayBuffer { + const mergedBuffer = new ArrayBuffer(buffer1.byteLength + buffer2.byteLength); + + const view1 = new Uint8Array(buffer1); + const view2 = new Uint8Array(buffer2); + const mergedView = new Uint8Array(mergedBuffer); + + mergedView.set(view1, 0); + mergedView.set(view2, buffer1.byteLength); + + return mergedBuffer; +} + +function generateIcoHeader(icon: IconEntry) { + const headerSize = 0x16; + const header = new Uint8Array(headerSize); + writeUint8(header, 2, 1); // Image type -> ico + writeUint8(header, 4, 1); // Image count + + const start = 6; + + writeUint8(header, start + 0, icon.width); + writeUint8(header, start + 1, icon.height); + writeUint8(header, start + 2, icon.colorCount); + + writeUint16(header, start + 4, icon.planes); + writeUint16(header, start + 6, icon.bitCount); + writeUint32(header, start + 8, icon.bytesInRes); + writeUint32(header, start + 12, headerSize); + + return header; +} + +function isMaxResIcon(icon: IconEntry) { + return icon.width == 0 && icon.height == 0; +} + +function getBiggestIcon(group: IconGroup) { + if (group.icons.length == 0) { + return null; + } + + var biggest = group.icons[0]; + if (isMaxResIcon(biggest)) { + return biggest; + } + + for (let i = 1; i < group.icons.length; ++i) { + let current = group.icons[i]; + if (isMaxResIcon(current)) { + return current; + } + + if (current.width * current.height > biggest.width * biggest.height) { + biggest = current; + } + } + + return biggest; +} + +function getPeResources(data: Uint8Array) { + const exe = PE.NtExecutable.from(data, { ignoreCert: true }); + patchExeFile(exe); + return PE.NtExecutableResource.from(exe, true); +} + +function getIconDataUrl(iconEntry: IconEntry, iconData: ArrayBuffer) { + let contentType = "image/png"; + + if (!isPng(new Uint8Array(iconData))) { + contentType = "image/x-icon"; + + const header = generateIcoHeader(iconEntry); + iconData = mergeArrayBuffers(header, iconData); + } + + return generateDataURL(new Uint8Array(iconData), contentType); +} + +export function parsePeIcon(data: Uint8Array) { + const res = getPeResources(data); + const icons = res.entries.filter((e) => e.type == 3); + const iconGroups = res.entries.filter((e) => e.type == 14); + + if (iconGroups.length == 0 || icons.length == 0) { + return null; + } + + const groupData = new Uint8Array(iconGroups[0].bin); + const group = parseIconGroup(groupData); + const iconEntry = getBiggestIcon(group); + + if (!iconEntry) { + return null; + } + + const icon = icons.find((i) => i.id == iconEntry.id); + if (!icon) { + return null; + } + + return getIconDataUrl(iconEntry, icon.bin); +}