Add macOS support for ProcessMemory plugin

Implement macOS-specific support for the ProcessMemory plugin and update plugin discovery/build.

- Add macOS build/install support: include plugins/ProcessMemory in top-level CMake and copy the built plugin into Reclass.app/Contents/PlugIns on macOS.
- Implement Apple-specific ProcessMemoryProvider: task_for_pid usage, mach_vm_read_overwrite/mach_vm_write, proc_pidpath/proc_regionfilename based module caching, region enumeration, symbol formatting, module enumeration, and proper cleanup (mach_port_deallocate).
- Extend plugin header to track m_task and adjust readability checks for macOS.
- Add macOS handling in getInitialBaseAddress and process enumeration to find base addresses and processes using proc APIs.
- Improve PluginManager to probe multiple plugin locations (including Contents/PlugIns inside macOS bundles), aggregate/log candidate counts, and continue scanning multiple dirs.
- Add macOS screenshot to README (docs/README_PIC6.png) and reference it in README.

These changes enable the ProcessMemory plugin to operate on macOS and make plugin discovery more robust on macOS app bundles.
This commit is contained in:
√(noham)²
2026-03-15 14:47:48 +01:00
parent dc6963e0d5
commit b4727df3e9
7 changed files with 412 additions and 23 deletions

View File

@@ -599,8 +599,8 @@ if(BUILD_TESTING)
endif() # BUILD_UI_TESTS
endif()
add_subdirectory(plugins/ProcessMemory)
if(NOT APPLE)
add_subdirectory(plugins/ProcessMemory)
add_subdirectory(plugins/RemoteProcessMemory)
endif()
if(WIN32)

View File

@@ -32,6 +32,8 @@ Built with C++17, Qt 6 (Qt 5 also supported), and QScintilla. The entire editor
![Memory scanner](docs/README_PIC3.png)
![MacOS - Process Memory scanner](docs/README_PIC6.png)
## Features
### Editor

BIN
docs/README_PIC6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

View File

@@ -45,3 +45,12 @@ set_target_properties(ProcessMemoryPlugin PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
)
if(APPLE AND TARGET Reclass)
add_custom_command(TARGET ProcessMemoryPlugin POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:Reclass>/../PlugIns"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:ProcessMemoryPlugin>"
"$<TARGET_FILE_DIR:Reclass>/../PlugIns/"
COMMENT "Copying ProcessMemoryPlugin into Reclass.app/Contents/PlugIns")
endif()

View File

@@ -10,6 +10,7 @@
#include <QImage>
#include <QDir>
#include <QFileInfo>
#include <QMap>
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) && defined(_WIN32)
#include <QtWin>
#endif
@@ -83,6 +84,13 @@ typedef struct alignas(8) _THREAD_BASIC_INFORMATION {
#include <fstream>
#include <sstream>
#include <cstring>
#elif defined(__APPLE__)
#include <mach/mach.h>
#include <mach/mach_vm.h>
#include <libproc.h>
#include <sys/proc_info.h>
#include <unistd.h>
#include <cstring>
#endif
// ──────────────────────────────────────────────────────────────────────────
@@ -476,8 +484,239 @@ QVector<rcx::MemoryRegion> ProcessMemoryProvider::enumerateRegions() const
return regions;
}
#elif defined(__APPLE__)
ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& processName)
: m_task(0)
, m_pid(pid)
, m_processName(processName)
, m_writable(false)
, m_base(0)
{
mach_port_t task = MACH_PORT_NULL;
kern_return_t kr = task_for_pid(mach_task_self(), static_cast<int>(pid), &task);
if (kr != KERN_SUCCESS || task == MACH_PORT_NULL)
return;
m_task = static_cast<uint32_t>(task);
m_writable = true;
proc_bsdinfo bsdInfo{};
int infoLen = proc_pidinfo(static_cast<int>(pid), PROC_PIDTBSDINFO, 0, &bsdInfo, sizeof(bsdInfo));
if (infoLen == (int)sizeof(bsdInfo)) {
#ifdef PROC_FLAG_LP64
m_pointerSize = (bsdInfo.pbi_flags & PROC_FLAG_LP64) ? 8 : 4;
#else
m_pointerSize = 8;
#endif
}
cacheModules();
}
bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const
{
if (m_task == 0 || len <= 0)
return false;
mach_vm_size_t outSize = 0;
kern_return_t kr = mach_vm_read_overwrite(
static_cast<mach_port_name_t>(m_task),
static_cast<mach_vm_address_t>(addr),
static_cast<mach_vm_size_t>(len),
reinterpret_cast<mach_vm_address_t>(buf),
&outSize);
if ((int)outSize < len)
memset((char*)buf + outSize, 0, len - outSize);
return kr == KERN_SUCCESS && outSize > 0;
}
bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len)
{
if (m_task == 0 || !m_writable || len <= 0)
return false;
kern_return_t kr = mach_vm_write(
static_cast<mach_port_name_t>(m_task),
static_cast<mach_vm_address_t>(addr),
reinterpret_cast<vm_offset_t>(const_cast<void*>(buf)),
static_cast<mach_msg_type_number_t>(len));
return kr == KERN_SUCCESS;
}
QString ProcessMemoryProvider::getSymbol(uint64_t addr) const
{
for (const auto& mod : m_modules)
{
if (addr >= mod.base && addr < mod.base + mod.size)
{
uint64_t offset = addr - mod.base;
return QStringLiteral("%1+0x%2")
.arg(mod.name)
.arg(offset, 0, 16, QChar('0'));
}
}
return {};
}
void ProcessMemoryProvider::cacheModules()
{
if (m_task == 0)
return;
m_modules.clear();
char mainPathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
QString mainPath;
if (proc_pidpath((int)m_pid, mainPathBuf, sizeof(mainPathBuf)) > 0)
mainPath = QString::fromUtf8(mainPathBuf);
struct Range { uint64_t base; uint64_t end; };
QMap<QString, Range> moduleRanges;
mach_vm_address_t addr = 0;
uint32_t depth = 0;
for (;;) {
mach_vm_size_t size = 0;
vm_region_submap_info_data_64_t info{};
mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
kern_return_t kr = mach_vm_region_recurse(
static_cast<mach_port_name_t>(m_task),
&addr,
&size,
&depth,
reinterpret_cast<vm_region_recurse_info_t>(&info),
&count);
if (kr != KERN_SUCCESS)
break;
if (info.is_submap) {
++depth;
continue;
}
if (size == 0) {
++addr;
continue;
}
char pathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
int pathLen = proc_regionfilename((int)m_pid, (uint64_t)addr, pathBuf, sizeof(pathBuf));
if (pathLen > 0) {
QString fullPath = QString::fromUtf8(pathBuf);
uint64_t regionBase = (uint64_t)addr;
uint64_t regionEnd = regionBase + (uint64_t)size;
auto it = moduleRanges.find(fullPath);
if (it == moduleRanges.end()) {
moduleRanges.insert(fullPath, {regionBase, regionEnd});
} else {
if (regionBase < it->base) it->base = regionBase;
if (regionEnd > it->end) it->end = regionEnd;
}
if (m_base == 0 && !mainPath.isEmpty() && fullPath == mainPath && (info.protection & VM_PROT_EXECUTE))
m_base = regionBase;
}
uint64_t next = (uint64_t)addr + (uint64_t)size;
if (next <= (uint64_t)addr)
break;
addr = (mach_vm_address_t)next;
}
m_modules.reserve(moduleRanges.size());
for (auto it = moduleRanges.begin(); it != moduleRanges.end(); ++it)
{
QFileInfo fi(it.key());
m_modules.push_back(ModuleInfo{
fi.fileName(),
it.key(),
it->base,
it->end - it->base
});
}
if (m_base == 0 && !m_modules.isEmpty())
m_base = m_modules.front().base;
}
QVector<rcx::MemoryRegion> ProcessMemoryProvider::enumerateRegions() const
{
QVector<rcx::MemoryRegion> regions;
if (m_task == 0)
return regions;
mach_vm_address_t addr = 0;
uint32_t depth = 0;
for (;;) {
mach_vm_size_t size = 0;
vm_region_submap_info_data_64_t info{};
mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
kern_return_t kr = mach_vm_region_recurse(
static_cast<mach_port_name_t>(m_task),
&addr,
&size,
&depth,
reinterpret_cast<vm_region_recurse_info_t>(&info),
&count);
if (kr != KERN_SUCCESS)
break;
if (info.is_submap) {
++depth;
continue;
}
if (size == 0) {
++addr;
continue;
}
bool readable = (info.protection & VM_PROT_READ) != 0;
if (readable)
{
rcx::MemoryRegion region;
region.base = (uint64_t)addr;
region.size = (uint64_t)size;
region.readable = readable;
region.writable = (info.protection & VM_PROT_WRITE) != 0;
region.executable = (info.protection & VM_PROT_EXECUTE) != 0;
char pathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
int pathLen = proc_regionfilename((int)m_pid, region.base, pathBuf, sizeof(pathBuf));
if (pathLen > 0) {
QFileInfo fi(QString::fromUtf8(pathBuf));
region.moduleName = fi.fileName();
}
regions.append(region);
}
uint64_t next = (uint64_t)addr + (uint64_t)size;
if (next <= (uint64_t)addr)
break;
addr = (mach_vm_address_t)next;
}
return regions;
}
#endif // platform
#ifndef _WIN32
QVector<rcx::Provider::ModuleEntry> ProcessMemoryProvider::enumerateModules() const
{
QVector<ModuleEntry> result;
result.reserve(m_modules.size());
for (const auto& m : m_modules)
result.push_back(ModuleEntry{m.name, m.fullPath, m.base, m.size});
return result;
}
#endif
uint64_t ProcessMemoryProvider::symbolToAddress(const QString& name) const
{
for (const auto& mod : m_modules) {
@@ -495,6 +734,9 @@ ProcessMemoryProvider::~ProcessMemoryProvider()
#elif defined(__linux__)
if (m_fd >= 0)
::close(m_fd);
#elif defined(__APPLE__)
if (m_task != 0)
mach_port_deallocate(mach_task_self(), static_cast<mach_port_name_t>(m_task));
#endif
}
@@ -504,6 +746,8 @@ int ProcessMemoryProvider::size() const
return m_handle ? 0x10000 : 0;
#elif defined(__linux__)
return (m_fd >= 0) ? 0x10000 : 0;
#elif defined(__APPLE__)
return (m_task != 0) ? 0x10000 : 0;
#endif
}
@@ -654,6 +898,68 @@ uint64_t ProcessMemoryPlugin::getInitialBaseAddress(const QString& target) const
}
}
return 0;
#elif defined(__APPLE__)
QStringList parts = target.split(':');
bool ok = false;
uint32_t pid = parts[0].toUInt(&ok);
if (!ok || pid == 0)
return 0;
mach_port_t task = MACH_PORT_NULL;
kern_return_t tkr = task_for_pid(mach_task_self(), static_cast<int>(pid), &task);
if (tkr != KERN_SUCCESS || task == MACH_PORT_NULL)
return 0;
char mainPathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
QString mainPath;
if (proc_pidpath((int)pid, mainPathBuf, sizeof(mainPathBuf)) > 0)
mainPath = QString::fromUtf8(mainPathBuf);
uint64_t base = 0;
mach_vm_address_t addr = 0;
uint32_t depth = 0;
for (;;) {
mach_vm_size_t size = 0;
vm_region_submap_info_data_64_t info{};
mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
kern_return_t kr = mach_vm_region_recurse(task, &addr, &size, &depth,
reinterpret_cast<vm_region_recurse_info_t>(&info),
&count);
if (kr != KERN_SUCCESS)
break;
if (info.is_submap) {
++depth;
continue;
}
if (size == 0) {
++addr;
continue;
}
if ((info.protection & VM_PROT_EXECUTE) != 0) {
if (!mainPath.isEmpty()) {
char pathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
int pathLen = proc_regionfilename((int)pid, (uint64_t)addr, pathBuf, sizeof(pathBuf));
if (pathLen > 0 && QString::fromUtf8(pathBuf) == mainPath) {
base = (uint64_t)addr;
break;
}
} else {
base = (uint64_t)addr;
break;
}
}
uint64_t next = (uint64_t)addr + (uint64_t)size;
if (next <= (uint64_t)addr)
break;
addr = (mach_vm_address_t)next;
}
mach_port_deallocate(mach_task_self(), task);
return base;
#else
Q_UNUSED(target);
return 0;
@@ -797,6 +1103,61 @@ QVector<PluginProcessInfo> ProcessMemoryPlugin::enumerateProcesses()
::close(exeFd);
}
processes.append(info);
}
#elif defined(__APPLE__)
QIcon defaultIcon = qApp->style()->standardIcon(QStyle::SP_ComputerIcon);
int bytes = proc_listpids(PROC_ALL_PIDS, 0, nullptr, 0);
if (bytes <= 0)
return processes;
int count = bytes / (int)sizeof(pid_t);
QVector<pid_t> pids(count);
bytes = proc_listpids(PROC_ALL_PIDS, 0, pids.data(), count * (int)sizeof(pid_t));
if (bytes <= 0)
return processes;
count = bytes / (int)sizeof(pid_t);
for (int i = 0; i < count; ++i) {
pid_t pid = pids[i];
if (pid <= 0)
continue;
mach_port_t task = MACH_PORT_NULL;
if (task_for_pid(mach_task_self(), pid, &task) != KERN_SUCCESS || task == MACH_PORT_NULL)
continue;
mach_port_deallocate(mach_task_self(), task);
char nameBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
int nameLen = proc_name(pid, nameBuf, sizeof(nameBuf));
QString procName;
if (nameLen > 0)
procName = QString::fromUtf8(nameBuf);
if (procName.isEmpty())
continue;
char pathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
QString procPath;
if (proc_pidpath(pid, pathBuf, sizeof(pathBuf)) > 0)
procPath = QString::fromUtf8(pathBuf);
PluginProcessInfo info;
info.pid = static_cast<uint32_t>(pid);
info.name = procName;
info.path = procPath;
info.icon = defaultIcon;
proc_bsdinfo bsdInfo{};
int infoLen = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &bsdInfo, sizeof(bsdInfo));
if (infoLen == (int)sizeof(bsdInfo)) {
#ifdef PROC_FLAG_LP64
info.is32Bit = (bsdInfo.pbi_flags & PROC_FLAG_LP64) == 0;
#else
info.is32Bit = false;
#endif
}
processes.append(info);
}
#endif

View File

@@ -35,6 +35,8 @@ public:
return m_handle && len >= 0;
#elif defined(__linux__)
return m_fd >= 0 && len >= 0;
#elif defined(__APPLE__)
return m_task != 0 && len >= 0;
#endif
}
@@ -53,6 +55,8 @@ private:
void* m_handle;
#elif defined(__linux__)
int m_fd;
#elif defined(__APPLE__)
uint32_t m_task;
#endif
uint32_t m_pid;
QString m_processName;

View File

@@ -12,16 +12,15 @@ PluginManager::~PluginManager()
void PluginManager::LoadPlugins()
{
// Get the Plugins directory relative to the executable
// Probe plugin locations relative to the executable.
QString appDir = QCoreApplication::applicationDirPath();
QString pluginsDir = appDir + "/Plugins";
QDir dir(pluginsDir);
if (!dir.exists())
{
qWarning() << "PluginManager: Plugins directory not found:" << pluginsDir;
return;
}
QStringList pluginDirs;
pluginDirs << (appDir + "/Plugins");
#ifdef __APPLE__
// In macOS app bundles, plugins may live in Contents/PlugIns or in
// the top-level build/Plugins directory during local development.
pluginDirs << QDir::cleanPath(appDir + "/../PlugIns");
#endif
// Find all DLL files
QStringList filters;
@@ -33,22 +32,36 @@ void PluginManager::LoadPlugins()
filters << "*.so";
#endif
dir.setNameFilters(filters);
QFileInfoList files = dir.entryInfoList(QDir::Files);
qDebug() << "PluginManager: Scanning for plugins in:" << pluginsDir;
qDebug() << "PluginManager: Found" << files.count() << "potential plugin(s)";
for (const QFileInfo& fileInfo : files)
int totalCandidates = 0;
bool foundAnyDir = false;
for (const QString& pluginsDir : pluginDirs)
{
// Skip the remote-inject payload binary — it's not a plugin and
// loading it (especially on Linux) spawns a rogue thread.
if (fileInfo.baseName().startsWith("rcx_payload"))
QDir dir(pluginsDir);
if (!dir.exists())
continue;
LoadPlugin(fileInfo.absoluteFilePath());
foundAnyDir = true;
dir.setNameFilters(filters);
QFileInfoList files = dir.entryInfoList(QDir::Files);
totalCandidates += files.count();
qDebug() << "PluginManager: Scanning for plugins in:" << pluginsDir;
for (const QFileInfo& fileInfo : files)
{
// Skip the remote-inject payload binary — it's not a plugin and
// loading it (especially on Linux) spawns a rogue thread.
if (fileInfo.baseName().startsWith("rcx_payload"))
continue;
LoadPlugin(fileInfo.absoluteFilePath());
}
}
if (!foundAnyDir)
qWarning() << "PluginManager: Plugins directory not found. Searched:" << pluginDirs;
else
qDebug() << "PluginManager: Found" << totalCandidates << "potential plugin(s)";
qDebug() << "PluginManager: Loaded" << m_plugins.count() << "plugin(s)";
}