fix(relinker): resolve symbol-name imports through NIDs on Windows

Homebrew built with the ps5-payload-dev SDK imports by symbol name and
names its system libraries with .sprx, while the libraries in libs/
are .prx files that export NIDs. Quake from the websrv launcher
stopped with 457 unresolved imports such as strlen and malloc.

An import without a module that no searched library exports under its
name is now looked up again under the NID of that name. A dependency
named with .sprx, .suprx or .debug_prx loads the .prx library of the
same stem, and libkernel_web also loads libkernel.prx, as
libSceLibcInternal already loads libc.prx.
This commit is contained in:
Karim Ibrahim committed 2026-10-17 22:40:09 -04:00
1 parent b39d90bf14
commit ea61baec62
6 files changed
+123 -9

No files matched your search

+5
View File
@@ -71,7 +71,10 @@ add_library(relinker_core STATIC
${CMAKE_CURRENT_SOURCE_DIR}/relinker/src/output/CallRegistryWriter.cpp
${CMAKE_CURRENT_SOURCE_DIR}/relinker/src/pipeline/Conversion.cpp
${CMAKE_CURRENT_SOURCE_DIR}/relinker/src/pipeline/RelinkerPipeline.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../libs/nid/src/NidCompute.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../libs/nid/src/Sha1.cpp
)
target_include_directories(relinker_core PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../libs/nid/include)
target_include_directories(relinker_core PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/domain/include
${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/include
@@ -512,6 +515,8 @@ if(BUILD_TESTING)
${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_macos_output.py $<TARGET_FILE:relinker>)
add_test(NAME guest_host_libc COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_guest_host_libc.py $<TARGET_FILE:relinker>)
add_test(NAME windows_named_imports COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_windows_named_imports.py $<TARGET_FILE:relinker>)
add_library(guest_module_args_host SHARED elfpatcher/tests/GuestModuleArgsHost.cpp)
target_include_directories(guest_module_args_host PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../libs)
set_target_properties(guest_module_args_host PROPERTIES
@@ -16,6 +16,12 @@ inline std::string_view ModuleSuffix(const std::string_view name) {
return suffix == ModuleSuffixes.end() ? std::string_view{} : *suffix;
}
inline std::string NeededLibraryName(std::string name) {
const auto suffix = ModuleSuffix(name);
if (!suffix.empty() && suffix != ".prx") name.replace(name.size() - suffix.size(), suffix.size(), ".prx");
return name;
}
inline std::string ImportModuleName(std::string name) {
name.resize(name.size() - ModuleSuffix(name).size());
if (name.ends_with("-module")) name.resize(name.size() - 7);
@@ -2,7 +2,9 @@
#include <elfpatcher/windows/WindowsStubEmitter.hpp>
#include <elfpatcher/windows/WindowsDependencyStubBuilder.hpp>
#include <elfpatcher/windows/WindowsGuestStartup.hpp>
#include <domain/ImportModule.hpp>
#include <io/BufferUtils.hpp>
#include <nid/NidCompute.hpp>
#include <algorithm>
#include <cctype>
#include <map>
@@ -132,8 +134,12 @@ WindowsEntryStub WindowsEntryStubBuilder::Build(const std::uint32_t dataRva, con
}
std::vector<std::uint32_t> symbolNames;
for (const auto& import : imports)
std::vector<std::optional<std::uint32_t>> nidNames;
for (const auto& import : imports) {
symbolNames.push_back(addString(import.Name));
const bool named = import.Library.empty() && !import.Name.empty() && import.Name.find('#') == std::string::npos;
nidNames.push_back(named ? std::optional(addString(Nid::ComputeNid(import.Name, ""))) : std::nullopt);
}
std::vector<std::uint32_t> searchTables;
std::vector<std::uint32_t> searchCounts;
@@ -141,22 +147,23 @@ WindowsEntryStub WindowsEntryStubBuilder::Build(const std::uint32_t dataRva, con
std::vector<bool> imported(libraries.size());
const bool unscopedImports = std::any_of(imports.begin(), imports.end(), [](const auto& import) { return import.Library.empty(); });
for (const auto& import : imports) {
const auto library = Domain::NeededLibraryName(import.Library);
std::vector<std::uint32_t> order;
for (std::size_t index = 0; index < libraries.size(); ++index) {
auto name = std::filesystem::path(libraries[index]).filename().string();
if (name.ends_with(".guest.prx")) name.resize(name.size() - 10);
const bool guestName = index < guestModules.size() &&
std::find(guestModules[index].Names.begin(), guestModules[index].Names.end(), import.Library) != guestModules[index].Names.end();
if (import.Library.empty() || name == import.Library || guestName) order.push_back(CheckedRva(index));
if (import.Library.empty() || name == library || guestName) order.push_back(CheckedRva(index));
}
if (order.empty()) throw Domain::RelinkerException("Import module is not a dependency: " + import.Library);
const bool hostModule = !import.Library.empty() &&
std::any_of(order.begin(), order.end(), [&](auto index) { return index >= guestModules.size(); });
if (hostModule) {
for (std::size_t index = guestModules.size(); index < libraries.size(); ++index) {
if (libraries[index] == "libc.prx" && import.Library != "libc.prx") order.push_back(CheckedRva(index));
if (libraries[index] == "libc.prx" && library != "libc.prx") order.push_back(CheckedRva(index));
}
if (import.Library == "libSceLibcInternal.prx") {
if (library == "libSceLibcInternal.prx") {
for (std::size_t index = 0; index < guestModules.size(); ++index) {
const auto& names = guestModules[index].Names;
if (std::find(names.begin(), names.end(), "libc.prx") != names.end() && !guestModules[index].ZeroReturnExports.contains(import.Name))
@@ -596,9 +603,23 @@ WindowsEntryStub WindowsEntryStubBuilder::Build(const std::uint32_t dataRva, con
code.Emit({0x8b, 0x0f, 0x48, 0x8b, 0x0c, 0xcb, 0x48, 0x89, 0xf2});
call("GetProcAddress");
code.Emit({0x48, 0x85, 0xc0});
const auto resolved = code.Branch({0x0f, 0x85});
std::vector<std::size_t> resolved = {code.Branch({0x0f, 0x85})};
code.Emit({0x48, 0x83, 0xc7, 4, 0xff, 0xcd});
code.Rip({0x0f, 0x85}, search);
if (nidNames[index]) {
code.Rip({0x48, 0x8d, 0x35}, *nidNames[index]);
code.Rip({0x48, 0x8d, 0x3d}, searchTables[index]);
code.Emit({0xbd});
code.U32(searchCounts[index]);
const auto nidSearch = code.GetRva();
code.Emit({0x8b, 0x0f, 0x48, 0x8b, 0x0c, 0xcb, 0x48, 0x89, 0xf2});
call("GetProcAddress");
code.Emit({0x48, 0x85, 0xc0});
resolved.push_back(code.Branch({0x0f, 0x85}));
code.Emit({0x48, 0x83, 0xc7, 4, 0xff, 0xcd});
code.Rip({0x0f, 0x85}, nidSearch);
}
const auto patchResolved = [&] { for (const auto branch : resolved) code.PatchBranch(branch, code.GetRva()); };
if (imports[index].Weak) {
code.Emit({0x31, 0xc0});
const auto writeWeak = code.GetRva();
@@ -609,7 +630,7 @@ WindowsEntryStub WindowsEntryStubBuilder::Build(const std::uint32_t dataRva, con
}
guestStartup.WriteImport(code, imports[index], handles);
const auto weakDone = code.Branch({0xe9});
code.PatchBranch(resolved, code.GetRva());
patchResolved();
code.Rip({0xe9}, writeWeak);
code.PatchBranch(weakDone, code.GetRva());
continue;
@@ -617,7 +638,7 @@ WindowsEntryStub WindowsEntryStubBuilder::Build(const std::uint32_t dataRva, con
if (lazyBinding) {
lazyUnresolvedImports.push_back(index);
const auto skipGotWrite = code.Branch({0xe9});
code.PatchBranch(resolved, code.GetRva());
patchResolved();
if (imports[index].Addend != 0) {
code.Emit({0x48, 0xba});
code.U64(imports[index].Addend);
@@ -631,7 +652,7 @@ WindowsEntryStub WindowsEntryStubBuilder::Build(const std::uint32_t dataRva, con
writeString(errorRvas.at(3 + index), true);
code.Rip({0xff, 0x05}, unresolvedCount);
const auto nextImport = code.Branch({0xe9});
code.PatchBranch(resolved, code.GetRva());
patchResolved();
if (imports[index].RelocationType == 16) code.Emit({0x48, 0x8b, 0x00});
if (imports[index].RelocationType == 17) code.Emit({0x48, 0x8b, 0x40, 8});
if (imports[index].Addend != 0) {
@@ -1,4 +1,5 @@
#include <elfpatcher/windows/WindowsImportBuilder.hpp>
#include <domain/ImportModule.hpp>
#include <io/BufferUtils.hpp>
#include <set>
@@ -43,7 +44,7 @@ std::vector<std::string> WindowsImportBuilder::ReadLibraries(const Domain::SysVD
const auto nameOffset = Io::ReadU64(bytes, offset + 8);
if (nameOffset >= dynamicSection.DynStrData.size())
throw Domain::RelinkerException("DT_NEEDED string offset is out of bounds", nameOffset);
auto name = ReadString(dynamicSection.DynStrData, static_cast<std::size_t>(nameOffset));
auto name = Domain::NeededLibraryName(ReadString(dynamicSection.DynStrData, static_cast<std::size_t>(nameOffset)));
if (name.empty() || name.find_first_of("/\\:") != std::string::npos)
throw Domain::RelinkerException("Invalid DT_NEEDED library: " + name);
if (unique.insert(name).second)
@@ -54,6 +55,8 @@ std::vector<std::string> WindowsImportBuilder::ReadLibraries(const Domain::SysVD
// Keep the requested module first, then make its shared runtime visible.
if ((unique.contains("libSceLibcInternal.prx") || unique.contains("libkernel.prx")) && !unique.contains("libc.prx"))
result.push_back("libc.prx");
if (unique.contains("libkernel_web.prx") && !unique.contains("libkernel.prx"))
result.push_back("libkernel.prx");
return result;
}
@@ -0,0 +1,78 @@
import os
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "macos"))
from guest_import_fixtures import executable
from nid import nid
from test_guest_tls import guest_with_tls
from test_windows_import_modules import provider
def native_library(relinker, case, exported, value):
modules = case / "prx"
modules.mkdir(parents=True)
image = provider(value)
name = b"\0" + exported.encode() + b"\0"
image[0x800:0x800 + len(name)] = name
struct.pack_into("<Q", image, 0x618, len(name))
(modules / "native.prx").write_bytes(image)
(case / "input.elf").write_bytes(executable("native.prx", exported + "#A#B"))
result = subprocess.run([str(relinker), "--windows", str(case / "input.elf"), str(case / "output.exe")],
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, (result.stdout, result.stderr)
return (case / "app0" / "prx" / "native.prx.guest.prx").read_bytes()
def convert(relinker, case, library, needed, symbol):
(case / "prx").mkdir(parents=True)
(case / "libs").mkdir()
(case / "prx" / "tls.prx").write_bytes(guest_with_tls(True))
(case / "libs" / "available.prx").write_bytes(library)
(case / "input.elf").write_bytes(executable(needed, symbol))
output = case / "output.exe"
result = subprocess.run([str(relinker), "--windows", str(case / "input.elf"), str(output)],
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, (result.stdout, result.stderr)
return output
def run(output):
return subprocess.run([str(output)], capture_output=True, text=True, timeout=30)
def main():
relinker = Path(sys.argv[1]).resolve()
with tempfile.TemporaryDirectory(prefix="anyps5-windows-named-imports-") as directory:
work = Path(directory)
by_nid = native_library(relinker, work / "nid-provider", nid("shared"), 22)
by_name = native_library(relinker, work / "name-provider", "shared", 33)
cases = {
"nid": convert(relinker, work / "nid", by_nid, "available.prx", "shared"),
"sprx": convert(relinker, work / "sprx", by_nid, "available.sprx", "shared"),
"name": convert(relinker, work / "name", by_name, "available.prx", "shared"),
"qualified": convert(relinker, work / "qualified", by_nid, "available.prx", "shared#A#B"),
"missing": convert(relinker, work / "missing", by_nid, "available.prx", "absent"),
}
if os.name != "nt":
return
result = run(cases["nid"])
assert result.returncode == 22, (result.returncode, result.stdout, result.stderr)
result = run(cases["sprx"])
assert result.returncode == 22, (result.returncode, result.stdout, result.stderr)
result = run(cases["name"])
assert result.returncode == 33, (result.returncode, result.stdout, result.stderr)
result = run(cases["qualified"])
assert result.returncode != 0 and "unresolved ELF import shared from available.prx" in result.stderr, (result.returncode, result.stderr)
result = run(cases["missing"])
assert result.returncode != 0 and "unresolved ELF import absent\n" in result.stderr, (result.returncode, result.stderr)
print("Windows named import tests passed")
if __name__ == "__main__":
main()
+1
View File
@@ -11,6 +11,7 @@
- [SDK memory-management syscall traps](../../../core/relinker/relinker/src/analysis/SyscallScanner.cpp) under `--trap-sdk-syscall-stubs` recognize three exact instruction sequences from [ps5-payload-dev SDK libc/mman.c](https://github.com/ps5-payload-dev/sdk/blob/12cf3dda40/libc/mman.c), observed in Nuvio Maxd 0.5.15 (PPSA99288): syscall 74 and the two syscall 477 paths, with their argument preparation and carry-flag error handling. Matching requires a decoded instruction boundary and the complete sequence inside the scanned code range. Other SDK/compiler output remains rejected. The matched syscall becomes `UD2`; this does not implement mmap or mprotect and a call still faults.
- The [instruction decoder](../../../core/relinker/codegen/src/x86/X64InstructionDecoder.cpp) reads a 16-bit displacement for `66 E8`, `66 E9` and `66 0F 80`-`8F` without REX.W, as AMD processors, the console included, do (binutils `objdump -M amd64`; REX.W selects the 32-bit form). AMD also truncates the target of every near relative branch with that prefix, rel8, LOOP and JRCXZ forms included, to 16 bits. Instruction metadata marks these branches as truncated and reports no branch target for them, and stubs refuse to move them. Compilers do not emit these forms.
- `--to-intel` does not lower RDPRU/MCOMMIT; the [matcher](../../../core/relinker/codegen/src/x86/Amd64OnlyInstructionMatcher.cpp) fails the relink instead.
- [Unscoped imports on Windows](../../../core/relinker/elfpatcher/src/windows/WindowsEntryStubBuilder.cpp) - an import without a module that no searched library exports under its name is searched again under the NID of that name, because homebrew built with the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk) imports by symbol name (Quake, from the websrv homebrew launcher; Mari0, #6239) while the libraries in `libs/` export NIDs. A dependency named with `.sprx`, `.suprx` or `.debug_prx` loads the `.prx` library of the same stem from `libs/`, and `libkernel_web` also loads `libkernel.prx`, as `libSceLibcInternal` already loads `libc.prx`: AnyPS5's `libkernel_web.prx` exports only two functions, where the console's `libkernel_web.sprx` is a libkernel variant. A name that matches a library's export as written still binds to that export first. Linux and macOS resolve these imports through the host loader by name and are unchanged.
- The length-changing path of the [instruction rewriter](../../../core/relinker/codegen/src/x86/X64InstructionRewriter.cpp) is not used by the [converter](../../../core/relinker/codegen/src/Amd64OnlyConverter.cpp): it does not adjust data-to-code references (relocations, FDEs, jump tables) or segment sizes, so every substitution keeps the instruction length.
- A `PT_SCE_RELRO` segment that no `PT_LOAD` covers, the PS4 layout, is [loaded](../../../core/relinker/relinker/src/parsing/ElfReader.cpp) as a read-write `PT_LOAD` and stays writable after relocation; FreeBSD rtld makes `PT_GNU_RELRO` read-only once relocation is done, and what the console does with `PT_SCE_RELRO` is not checked.
- In executables with a `PT_SCE_DYNLIBDATA` segment, the PS4 layout, the executable segment also holds read-only data after the code (CUSA00900: 37 MiB after its last function). The [relinker pipeline](../../../core/relinker/relinker/src/pipeline/RelinkerPipeline.cpp) therefore scans for syscalls and import call sites only up to the end of the PLT that follows the last function of the unwind table; code after the PLT without an unwind entry is not scanned. `--to-intel` and the `unused-filter=1` and `unused-filter=2` analyses are not limited this way.