From 793bce11fd74ea3dc94397efe3df5f1e7cf00150 Mon Sep 17 00:00:00 2001 From: John U <53329154+jdu2600@users.noreply.github.com> Date: Fri, 29 Sep 2023 09:43:35 +0800 Subject: [PATCH] BSides Canberra 2023 (#2) --- .gitignore | 398 ++++++++++++ .gitmodules | 3 + Get-InjectedThreadEx.cpp | 569 ++++++++++++++++++ Get-InjectedThreadEx/CfgBitMap.cpp | 49 ++ Get-InjectedThreadEx/Get-InjectedThreadEx.h | 89 +++ Get-InjectedThreadEx/Get-InjectedThreadEx.sln | 142 +++++ .../Get-InjectedThreadEx.vcxproj | 106 ++++ .../Get-InjectedThreadEx.vcxproj.filters | 45 ++ Get-InjectedThreadEx/Memory.cpp | 114 ++++ Get-InjectedThreadEx/Process.cpp | 68 +++ Get-InjectedThreadEx/StackClimb.cpp | 118 ++++ Get-InjectedThreadEx/Symbol.cpp | 154 +++++ Get-InjectedThreadEx/Unwind.cpp | 296 +++++++++ Get-InjectedThreadEx/zydis | 1 + LICENSE | 23 + 15 files changed, 2175 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Get-InjectedThreadEx.cpp create mode 100644 Get-InjectedThreadEx/CfgBitMap.cpp create mode 100644 Get-InjectedThreadEx/Get-InjectedThreadEx.h create mode 100644 Get-InjectedThreadEx/Get-InjectedThreadEx.sln create mode 100644 Get-InjectedThreadEx/Get-InjectedThreadEx.vcxproj create mode 100644 Get-InjectedThreadEx/Get-InjectedThreadEx.vcxproj.filters create mode 100644 Get-InjectedThreadEx/Memory.cpp create mode 100644 Get-InjectedThreadEx/Process.cpp create mode 100644 Get-InjectedThreadEx/StackClimb.cpp create mode 100644 Get-InjectedThreadEx/Symbol.cpp create mode 100644 Get-InjectedThreadEx/Unwind.cpp create mode 160000 Get-InjectedThreadEx/zydis create mode 100644 LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a30d25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,398 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..7cd19b2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Get-InjectedThreadEx/zydis"] + path = Get-InjectedThreadEx/zydis + url = https://github.com/zyantific/zydis diff --git a/Get-InjectedThreadEx.cpp b/Get-InjectedThreadEx.cpp new file mode 100644 index 0000000..6885d28 --- /dev/null +++ b/Get-InjectedThreadEx.cpp @@ -0,0 +1,569 @@ +// Get-InjectedThreadEx.cpp : A C++ implementation of Get-InjectedThreadEx.ps1 +// +// .SYNOPSIS +// +// Looks for threads that were created as a result of code injection. +// +// .DESCRIPTION +// +// Memory resident malware (fileless malware) often uses a form of memory injection to get code execution. +// Get-InjectedThreadEx looks at each running thread to determine if it is the result of memory injection. +// +// Win32StartAddress +// +// original +// - not MEM_IMAGE +// new +// - not MEM_COMMIT +// - MEM_IMAGE and Win32StartAddress is on a private (modified) page +// - MEM_IMAGE and x64 dll and Win32StartAddress is CFG violation or suppressed export +// - MEM_IMAGE and Win32StartAddress is in a suspicious module +// - MEM_IMAGE and x64 and Win32StartAddress is unexpected prolog +// - MEM_IMAGE and Win32StartAddress is preceded by unexpected bytes +// - MEM_IMAGE and x64 and Win32StartAddress wraps non-MEM_IMAGE start address +// +// KNOWN LIMITATIONS: +// - Only detects suspicious thread creations - not hijacks of existing threads. +// - Some WoW64 support not implemented. + +#include "Get-InjectedThreadEx/Get-InjectedThreadEx.h" + +BOOL ScanThread(HANDLE hProcess, BOOL bIsDotNet, PSS_THREAD_ENTRY& thread, std::string& symbol, std::vector& detections) { + if (thread.Flags & PSS_THREAD_FLAGS_TERMINATED) + return FALSE; + + if (!GetNearestSymbol(hProcess, thread.Win32StartAddress, symbol, true)) + return FALSE; + + if (thread.ContextRecord && thread.ContextRecord->Dr6) + detections.push_back("hw_breakpoint"); + + MEMORY_BASIC_INFORMATION mbi{}; + if (!VirtualQueryEx(hProcess, thread.Win32StartAddress, &mbi, sizeof(mbi))) + return FALSE; + + if (MEM_IMAGE != mbi.Type && MEM_COMMIT == mbi.State) { + detections.push_back("PRIVATE"); + return TRUE; + } + + // Has our MEM_IMAGE Win32StartAddress been (naively) hooked? + // https://blog.redbluepurple.io/offensive-research/bypassing-injection-detection//creating-the-thread + // Note - checking against bytes on disk after the fact won't help with false positives + // as the hook can easily be removed after thread start. + // Detection gap - the hook could easily be deeper, potentially even in a subsequent call. :-( + // Microsoft-Windows-Threat-Intelligence ETW events should detect this more robustly. + PSAPI_WORKING_SET_EX_INFORMATION pwsei{}; + pwsei.VirtualAddress = thread.Win32StartAddress; + if (K32QueryWorkingSetEx(hProcess, &pwsei, sizeof(pwsei)) && !pwsei.VirtualAttributes.Shared) { + // I'm slightly worried about security vendor hooks landing on the same page + // as ntdll!TppWorkerThread and causing a false positive flood. + // So ignore ntdll modifications if we've been hooked too. + static bool s_edrFalsePositive = symbol.starts_with("ntdll.dll!") && + K32QueryWorkingSetEx(GetCurrentProcess(), &pwsei, sizeof(pwsei)) && !pwsei.VirtualAttributes.Shared; + if(!s_edrFalsePositive) + detections.push_back("private_image"); + } + + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Check for suspcious CFG BitMap states in our local pristine copy of the x64 bitmap + // Notes - executable CFG bitmaps are not shared - only library (dll) ones. + // - only 16-bytes aligned addresses, as this is a SetProcessValidCallTargets() requirement. + ULONG cfgBits; + if (InSystemImageRange(thread.Win32StartAddress) && 0 == ((ULONG_PTR)thread.Win32StartAddress & 0xF) && + GetCfgBitsForAddress(thread.Win32StartAddress, &cfgBits) && 0 == cfgBits) + { + detections.push_back("cfg_invalid"); + } + + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Suspicious start modules + + std::wstring mappedPath; + // The file path assocated with Win32StartAddressModule + if (!GetMappedFileNameAsDosPath(hProcess, thread.Win32StartAddress, mappedPath)) + return FALSE; + + // There are no valid thread entry points (that I know of) in many Win32 modules. + const std::array modulesWithoutThreadEntrypoints = { + "kernel32", "kernelbase", "user32", "advapi32", + "psapi", "dbghelp", "imagehlp", "powrprof", + "verifier", "setupapi", "rpcrt4" }; // ...and many more + const auto startModule = std::filesystem::path(mappedPath).stem().string(); + for (const auto& module : modulesWithoutThreadEntrypoints) + if (startModule == module) { + (void)GetNearestSymbolWithPdb(hProcess, thread.Win32StartAddress, symbol); + detections.push_back("unexpected(" + startModule + ")"); + } + + // kernel32!LoadLibrary + // And, even if there are, LoadLibrary is always a suspicious start address. + static auto hKernel32 = GetModuleHandleW(L"kernel32.dll"); + static auto pLoadLibraryW = GetProcAddress(hKernel32, "LoadLibraryW"); + static auto pLoadLibraryA = GetProcAddress(hKernel32, "LoadLibraryA"); + if (pLoadLibraryA == thread.Win32StartAddress || pLoadLibraryW == thread.Win32StartAddress) + detections.push_back("unexpected(" + symbol + ")"); + + // ntdll.dll but not a known entrypoint. + // These are the only valid thread entry points in ntdll that I know of. + static const std::array ntdllThreadEntryPoints = { + GetSymbolAddress("ntdll!TppWorkerThread"), + GetSymbolAddress("ntdll!EtwpLogger"), + GetSymbolAddress("ntdll!DbgUiRemoteBreakin"), + GetSymbolAddress("ntdll!RtlpQueryProcessDebugInformationRemote") + }; + + if (mappedPath.ends_with(L"\\System32\\ntdll.dll")) { + auto bInvalidNtdllEntry = true; + for (const auto& address : ntdllThreadEntryPoints) + bInvalidNtdllEntry &= address == thread.Win32StartAddress; + if (bInvalidNtdllEntry) { + detections.push_back("unexpected(" + symbol + ")"); + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Common setup for the disassembler + constexpr auto MAX_INSN_LENGTH = 11ull; // theoretically 15, but empirically lower + ZydisDecoder decoder; + ZydisDecodedInstruction instruction; + // WoW64 can be inferred from the TEB address. + const bool bIsWow64 = (ULONG_PTR)thread.TebBaseAddress < 0x80000000; + if (bIsWow64) + (void)ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_COMPAT_32, ZYDIS_STACK_WIDTH_32); + else + (void)ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_64, ZYDIS_STACK_WIDTH_64); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Check the bytes immmediately after Win32StartAddress + // They must be a function entrypoint. + // ... but almost anything is a valid entrypoint! + // x64 prologs have more structure (albiet mostly by convention) - so we'll stick those. + // See https://learn.microsoft.com/en-us/cpp/build/prolog-and-epilog + // + // Note - the loader ignores AddressOfEntry in CLR assemblies so we need to ignore them too. + auto bIsDotNetProcessEntrypoint = bIsDotNet && mappedPath.ends_with(L".exe"); + + if (!bIsWow64 && !bIsDotNetProcessEntrypoint) { + std::string startBytes; + constexpr auto MAX_PROLOG_SIZE = 64; + startBytes.resize(MAX_PROLOG_SIZE); + if (!ReadProcessMemorySafely(hProcess, thread.Win32StartAddress, startBytes, mbi)) + return FALSE; + + auto i = 0; + bool bValidInstruction = true; + ZyanU64 instructionPointer = (ZyanU64)thread.Win32StartAddress; // track this to calculate relative targets + auto framePointer = ZYDIS_REGISTER_RSP; + ZydisDecoderContext ctx{}; + ZydisDecodedOperand operands[ZYDIS_MAX_OPERAND_COUNT]; + ZydisRegisterContext registers{}; + std::string originalBytes; // if we follow a jump keep original bytes + + const auto IsStackOperation = [&]() -> bool { + return ZYAN_SUCCESS(ZydisDecoderDecodeOperands(&decoder, &ctx, &instruction, operands, ZYDIS_MAX_OPERAND_COUNT)) && + ZYDIS_OPERAND_TYPE_REGISTER == operands[0].type && ZYDIS_REGISTER_RSP == operands[0].reg.value; + + }; + + const auto IsSaveRegisterOperation = [&]() -> bool { + return ZYAN_SUCCESS(ZydisDecoderDecodeOperands(&decoder, &ctx, &instruction, operands, ZYDIS_MAX_OPERAND_COUNT)) && + ZYDIS_OPERAND_TYPE_MEMORY == operands[0].type && (ZYDIS_REGISTER_RSP == operands[0].mem.base || framePointer == operands[0].mem.base) && + ZYDIS_OPERAND_TYPE_REGISTER == operands[1].type; + }; + + const auto IsFramePointerOperation = [&]() { + const auto bIsFP = ZYAN_SUCCESS(ZydisDecoderDecodeOperands(&decoder, &ctx, &instruction, operands, ZYDIS_MAX_OPERAND_COUNT)) && + ZYDIS_OPERAND_TYPE_REGISTER == operands[0].type && + ((ZYDIS_OPERAND_TYPE_REGISTER == operands[1].type && (ZYDIS_REGISTER_RSP == operands[1].reg.value || framePointer == operands[1].reg.value)) || + (ZYDIS_OPERAND_TYPE_MEMORY == operands[1].type && (ZYDIS_REGISTER_RSP == operands[1].mem.base || framePointer == operands[1].mem.base))); + if (bIsFP) + framePointer = operands[0].reg.value; + return bIsFP; + }; + + const auto IsRegDestination = [&](ZydisRegister reg) -> bool { + return ZYAN_SUCCESS(ZydisDecoderDecodeOperands(&decoder, &ctx, &instruction, operands, ZYDIS_MAX_OPERAND_COUNT)) && + ZYDIS_OPERAND_TYPE_REGISTER == operands[0].type && reg == operands[0].reg.value; + }; + + const auto IsRegSource = [&](ZydisRegister reg) -> bool { + return ZYAN_SUCCESS(ZydisDecoderDecodeOperands(&decoder, &ctx, &instruction, operands, ZYDIS_MAX_OPERAND_COUNT)) && + ((ZYDIS_OPERAND_TYPE_REGISTER == operands[1].type && reg == operands[1].reg.value) || + (ZYDIS_OPERAND_TYPE_MEMORY == operands[1].type && reg == operands[1].mem.base)); + }; + + const auto SaveRegister = [&](ZydisRegister reg) -> void { + ZyanU64 regValue = 0; + registers.values[reg] = 0; + // Note - assumes prior call to IsRegDestination + if (ZYDIS_OPERAND_TYPE_REGISTER == operands[0].type && reg == operands[0].reg.value && + ZYAN_SUCCESS(ZydisCalcAbsoluteAddress(&instruction, &operands[1], instructionPointer, ®Value))) + { + registers.values[reg] = regValue; + } + }; + + bool bThreadParameterInRax = false; + bool bCallThreadParameter = false; + bool bRaxSetLastInstruction = false; + int nFollowedJumps = 0; + constexpr auto MAX_JUMPS = 3; + const auto FollowJump = [&]() -> bool { + ZyanU64 jmpTarget = 0; + if (nFollowedJumps < MAX_JUMPS && + ZYAN_SUCCESS(ZydisDecoderDecodeOperands(&decoder, &ctx, &instruction, operands, ZYDIS_MAX_OPERAND_COUNT)) && + ZYAN_SUCCESS(ZydisCalcAbsoluteAddressEx(&instruction, &operands[0], instructionPointer, ®isters, &jmpTarget)) && + 0 != jmpTarget) + { + // If the jump target isn't an immediate then we need to read it from the calculated address. + if ((ZYDIS_OPERAND_TYPE_MEMORY == operands[0].type && ZYDIS_REGISTER_RIP == operands[0].mem.base)) + { + // If RCX has been mixed into RAX then we can't follow the jump. + // Such start addresses are useful proxy call functions for adversaries. + if (bRaxSetLastInstruction && bThreadParameterInRax) { + bCallThreadParameter = true; + return FALSE; + } + + // This looks like a CFG check. + // mov RAX, indirect-call + // jmp __guard_dispatch_icall_fptr + // The CFG thunk will transfer execution to RAX on success - so just jump to RAX now. + if (bRaxSetLastInstruction && InSystemImageRange((PVOID)jmpTarget)) + jmpTarget = registers.values[ZYDIS_REGISTER_RAX]; + + // Read the jump target + if (!ReadProcessMemorySafely(hProcess, (PVOID)jmpTarget, &jmpTarget)) + return FALSE; + } + + std::string jmpBytes = std::move(startBytes); + startBytes.resize(MAX_PROLOG_SIZE); + if (ReadProcessMemorySafely(hProcess, (PVOID)jmpTarget, startBytes)) { + + jmpBytes.resize(i + instruction.length); + originalBytes += ToHex(jmpBytes) + "|"; + + // reset loop for new bytes + instructionPointer = jmpTarget; + instruction.length = 0; + i = 0; + return TRUE; + } + startBytes = std::move(originalBytes); + } + return FALSE; + }; + + bool bPrologStarted = false; + bool bPrologFinished = false; + bool bRaxSet = false; + bool bTestRcx = false; + for (i = 0; bValidInstruction && !bPrologFinished && i <= startBytes.size() - MAX_INSN_LENGTH; i += instruction.length) { + bValidInstruction = ZYAN_SUCCESS(ZydisDecoderDecodeInstruction(&decoder, &ctx, startBytes.data() + i, startBytes.length() - i, &instruction)); + bRaxSetLastInstruction = bRaxSet; + bRaxSet = false; + switch (instruction.mnemonic) { + case ZYDIS_MNEMONIC_PUSH: + // push nonvolatile + bPrologStarted = true; + break; + case ZYDIS_MNEMONIC_MOV: + // mov [RSP+n], nonvolatile + if (IsSaveRegisterOperation()) + bPrologStarted = true; + // mov frame-pointer, RSP + else if (IsFramePointerOperation()) + bPrologStarted = true; + // mov RAX, fixed-allocation-size + // mov RAX, indirect-call-target + else if (IsRegDestination(ZYDIS_REGISTER_RAX)) { + SaveRegister(ZYDIS_REGISTER_RAX); + bRaxSet = true; + bThreadParameterInRax |= IsRegSource(ZYDIS_REGISTER_RCX); + } + else if (IsRegDestination(ZYDIS_REGISTER_EAX)) + bRaxSet = true; + // sometimes stub functions reorder parameters or set static values + // mov FastcallParamReg, * + else + bValidInstruction = IsRegDestination(ZYDIS_REGISTER_RCX) || IsRegDestination(ZYDIS_REGISTER_RDX) || IsRegDestination(ZYDIS_REGISTER_R8) || IsRegDestination(ZYDIS_REGISTER_R9); + break; + case ZYDIS_MNEMONIC_CALL: + // call __chkstk() is the only call allowed in a prolog + // It uses a special calling convention. + bValidInstruction = bRaxSetLastInstruction; + break; + case ZYDIS_MNEMONIC_LEA: + // lea frame-pointer, [RSP-n] + if (IsFramePointerOperation()) + bValidInstruction = true; + // lea RAX,[RIP+n] + else if (IsRegDestination(ZYDIS_REGISTER_RAX)) { + SaveRegister(ZYDIS_REGISTER_RAX); + bRaxSet = true; + } + // Some "functions" are just stubs around other functions with + // one (or more) fixed parameters. + // lea RCX, [n] - set first parameter. + else + bValidInstruction = IsRegDestination(ZYDIS_REGISTER_RCX); + break; + case ZYDIS_MNEMONIC_SUB: // prolog delimiter + bPrologFinished = IsStackOperation(); + break; + case ZYDIS_MNEMONIC_TEST: + // test RCX, RCX - is the first parameter NULL? + // Checking for a non-NULL parameter and bailing early is + // a common optimisation. + bTestRcx = IsRegSource(ZYDIS_REGISTER_RCX) && IsRegDestination(ZYDIS_REGISTER_RCX); + break; + case ZYDIS_MNEMONIC_JZ: + // test RCX, RCX + // jz early-exit - don't follow + bValidInstruction = bTestRcx; + break; + case ZYDIS_MNEMONIC_JNZ: + // test RCX, RCX + // jnz true-entry-point - follow + bValidInstruction = bTestRcx; + if (!FollowJump()) + bPrologFinished = bTestRcx; + break; + case ZYDIS_MNEMONIC_JMP: + // Some functions start with a short jmp to provide hotpatch space. + // jmp n - follow + bPrologFinished = FollowJump(); + break; + default: + bValidInstruction = false; + } + instructionPointer += instruction.length; + } + startBytes.resize(i); + if (bCallThreadParameter) + detections.push_back(std::string("proxy_call(" + originalBytes + ToHex(startBytes) + ")")); + else if (!bPrologFinished) + detections.push_back(std::string("prolog(" + originalBytes + ToHex(startBytes) + ")")); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Check the bytes immmediately before Win32StartAddress + // The byte preceding a function prolog is typically a return, or filler byte. + // False positives can occur if data was included in a code section. This was + // common in older compilers... + std::string tailBytes; + tailBytes.resize(std::min(MAX_INSN_LENGTH, (ULONG_PTR)thread.Win32StartAddress - (ULONG_PTR)mbi.AllocationBase)); + if (!ReadProcessMemorySafely(hProcess, (PVOID)((ULONG_PTR)thread.Win32StartAddress - tailBytes.size()), tailBytes)) + return FALSE; + + + // False positives can occur if data was included in a code section. This was common in older compilers... + // ...and also in new compilers that support XFG. In this case, the 8-byte XFG hash is immediately before. + // https://blog.quarkslab.com/how-the-msvc-compiler-generates-xfg-function-prototype-hashes.html + const auto& tailbytesEnd = tailBytes.data() + tailBytes.size(); + auto bIsValidTail = tailBytes.size() >= sizeof(UINT64) && + IsValidXfgHash(*(UINT64*)(tailbytesEnd - sizeof(UINT64))); + + // The byte preceding a function prolog is typically a return, or filler byte. + bIsValidTail |= tailBytes.empty() || '\x00' == tailBytes.back(); // NUL filled. + for (auto i = 1; !bIsValidTail && i <= tailBytes.size(); i++) { + if (!ZYAN_SUCCESS(ZydisDecoderDecodeInstruction(&decoder, NULL, tailbytesEnd - i, i, &instruction)) || instruction.length != i) + continue; + switch (instruction.mnemonic) { + // valid basic block end instructions + case ZYDIS_MNEMONIC_CALL: + case ZYDIS_MNEMONIC_JMP: + case ZYDIS_MNEMONIC_RET: + // valid alignment filler instructions + case ZYDIS_MNEMONIC_NOP: + case ZYDIS_MNEMONIC_INT3: + bIsValidTail = true;; + } + } + + if (!bIsValidTail) + detections.push_back(std::string("tail(" + ToHex(tailBytes) + ")")); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Check for suspicious call stacks + // [expected] ntdll!RtlUserThreadStart -> kernel32!BaseThreadInitThunk -> Win32StartAddress + // https://www.trustedsec.com/blog/avoiding-get-injectedthread-for-internal-thread-creation/ + // + + if (bIsWow64) + return TRUE; // TODO(jdu) Implement x86 stack climbing? + + // The TIB is the first element of the TEB. Read the TIB to determine the stack limits. + NT_TIB64 tib; + if (!ReadProcessMemory(hProcess, thread.TebBaseAddress, &tib, sizeof(tib), NULL)) + return FALSE; + + // Determine the consumed stack size (and check for stack pivoting such as ROP). + const auto stackPointer = thread.ContextRecord ? thread.ContextRecord->Rsp : tib.StackLimit; + if (stackPointer > tib.StackBase || stackPointer < tib.StackLimit) { + detections.push_back("stack_pivot"); + return TRUE; + } + + // Read the (partial) base of stack contents - 1.5 pages seems sufficient given current stack randomisation + PVOID stackBuffer[0x1800 / sizeof(PVOID)]; + const auto stackReadLength = std::min(sizeof(stackBuffer), (tib.StackBase - stackPointer) & ~0xF); + if (!ReadProcessMemory(hProcess, (PVOID)(tib.StackBase - stackReadLength), stackBuffer, stackReadLength, NULL)) + return FALSE; + + + // Search the stack bottom up for the (probable) initial return addresses of the first 3+2 frames. + // Note - x64 stack frames are 16-byte aligned. + std::vector callStackFrames; + bool bCallStackDetection = false; + const auto stackBufferCount = stackReadLength / sizeof(PVOID); + if (!StackClimb64(hProcess, stackBuffer, stackBufferCount, callStackFrames, &bCallStackDetection)) + return FALSE; + + // If the thread has been hijacked, then the return address alignment might be off. + // Search the skipped offsets this time. + if(0 == callStackFrames.size() && !StackClimb64(hProcess, stackBuffer, stackBufferCount, callStackFrames, &bCallStackDetection, 1)) + return FALSE; + + // Not enough stack frames discovered yet - append RIP + if (!bCallStackDetection && thread.ContextRecord && callStackFrames.size() < MIN_FRAMES) { + + if (!VirtualQueryEx(hProcess, (PVOID)thread.ContextRecord->Rip, &mbi, sizeof(mbi))) + return FALSE; + + if (!IsExecutable(mbi)) + LogError("pid:%d, tid:%d RIP:%llx is not executable", thread.ProcessId, thread.ThreadId, thread.ContextRecord->Rip); + + if (MEM_IMAGE != mbi.Type) { + callStackFrames.push_back("PRIVATE"); + bCallStackDetection = true; + } + } + + std::string callStackSummary; + callStackSummary.reserve(callStackFrames.size() * 16); + for (const auto& entry : callStackFrames) { + if (entry == "ntdll.dll!RtlUserThreadStart" || entry == "kernel32.dll!BaseThreadInitThunk") + continue; // skip common frames + if (!callStackSummary.empty()) + callStackSummary += "|"; + callStackSummary += entry.substr(0, entry.find_first_of('<')); // trim type information + } + + if (bCallStackDetection) + if(callStackFrames.size() < 4) + detections.push_back("spoof(" + callStackSummary + ")"); + else + detections.push_back("wrapper(" + callStackSummary + ")"); + + return TRUE; +} + +struct FalsePositive { + const std::wstring ProcessName; + const std::string Symbol; + const size_t Count; +}; + +BOOL IsKnownFalsePositive(const HANDLE hProcess, const PROCESSENTRY32 &processEntry, const PSS_THREAD_ENTRY thread, std::string &symbol, const std::vector &detections) { + + static const std::array falsePositives = { { + { L"dwm.exe", "dwmcore.dll!CMit::RunInputThreadStatic", 1 }, + { L"vctip.exe", "vctip.exe!CorExeMain", 1 } + } }; + + (void)GetNearestSymbolWithPdb(hProcess, thread.Win32StartAddress, symbol); + + BOOL bIsFalsePositive = FALSE; + for (const auto &fp : falsePositives) + bIsFalsePositive |= processEntry.szExeFile == fp.ProcessName && symbol == fp.Symbol && detections.size() == fp.Count; + + return bIsFalsePositive; +} + +int main(int, char* []) { + if (!IsElevated()) + LogError("WARNING Not running as Administrator"); + + BOOLEAN _; + if(!NT_SUCCESS(RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, TRUE, FALSE, &_))) + LogError("WARNING RtlAdjustPrivilege(DEBUG) failed"); + + const auto tsScanStarted = GetTickCount64(); + UINT32 nProcessesTotal = 0; + UINT32 nProcessesScanned = 0; + UINT32 nThreadsScanned = 0; + + //////////////////////////////////////////////////////////////////////////////// + // Scan each process + HANDLE hProcessSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (hProcessSnapshot == INVALID_HANDLE_VALUE) { + LogError("CreateToolhelp32Snapshot(PROCESS) failed. LastError:%d", GetLastError()); + return 0; + } + + PROCESSENTRY32 processEntry{}; + processEntry.dwSize = sizeof(PROCESSENTRY32); + if (!Process32First(hProcessSnapshot, &processEntry)) { + LogError("Process32First failed. LastError:%d", GetLastError()); + return 0; + } + + do { + if (processEntry.th32ProcessID <= 4) + continue; // skip Idle and System + + nProcessesTotal++; + HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processEntry.th32ProcessID); + if (NULL == hProcess) + continue; // skip process - Access is Denied, or process stopped + + auto bIsDotNet = IsDotNet(hProcess); + + ////////////////////////////////////////////////////////////////////////////////////////// + // Scan all threads in the process + HPSS hThreadSnapshot = NULL; + HPSSWALK hWalk = NULL; + const auto captureFlags = PSS_CAPTURE_THREADS | PSS_CAPTURE_THREAD_CONTEXT; + const auto contextFlags = CONTEXT_CONTROL | CONTEXT_DEBUG_REGISTERS; + if (S_OK == PssCaptureSnapshot(hProcess, captureFlags, contextFlags, &hThreadSnapshot) && S_OK == PssWalkMarkerCreate(NULL, &hWalk)) { + nProcessesScanned++; + PSS_THREAD_ENTRY thread; + while (S_OK == PssWalkSnapshot(hThreadSnapshot, PSS_WALK_THREADS, hWalk, &thread, sizeof(thread))) { + std::string symbol; + std::vector detections; + (void)ScanThread(hProcess, bIsDotNet, thread, symbol, detections); + nThreadsScanned++; + + if (detections.size() > 0 && !IsKnownFalsePositive(hProcess, processEntry, thread, symbol, detections)) { + (void)GetNearestSymbolWithPdb(hProcess, thread.Win32StartAddress, symbol, true); + Log("ProcessName : %S", processEntry.szExeFile); + Log("pid:tid : %d:%d", thread.ProcessId, thread.ThreadId); + Log("Win32StartAddress : %s", symbol.c_str()); + Log("Detections :"); + for (const auto& detection : detections) + Log(" - %s", detection.c_str()); + Log(""); + } + } + } + + if (NULL != hProcess) + (void)CloseHandle(hProcess); + if (NULL != hThreadSnapshot) + (void)PssFreeSnapshot(GetCurrentProcess(), hThreadSnapshot); + if (NULL != hWalk) + (void)PssWalkMarkerFree(hWalk); + } while (Process32Next(hProcessSnapshot, &processEntry)); + + if (INVALID_HANDLE_VALUE != hProcessSnapshot) + (void)CloseHandle(hProcessSnapshot); + + Log("Scanned %d threads in %d (of %d) processes in %.2f seconds", nThreadsScanned, nProcessesScanned, nProcessesTotal, (GetTickCount64() - tsScanStarted) / 1000.0); + + return 0; +} diff --git a/Get-InjectedThreadEx/CfgBitMap.cpp b/Get-InjectedThreadEx/CfgBitMap.cpp new file mode 100644 index 0000000..903f54a --- /dev/null +++ b/Get-InjectedThreadEx/CfgBitMap.cpp @@ -0,0 +1,49 @@ +#include "Get-InjectedThreadEx.h" + +BOOL InSystemImageRange(PVOID Address) { + return Address >= (PVOID)0x7FF800000000 && Address < (PVOID)0x7FFFFFFF0000; +} + +constexpr UINT64 XFG_MASK_UNSET = ~0xFFFDBFFF7EDFFB71; // mask of unset bits +constexpr UINT64 XFG_MASK_SET = 0x8000060010500070; // mask of set bits +constexpr UINT64 XFG_MASK_ALL = XFG_MASK_UNSET | XFG_MASK_SET; +BOOL IsValidXfgHash(UINT64 xfgHash) { + return XFG_MASK_SET == (xfgHash & XFG_MASK_ALL); +} + +static PULONG_PTR FindCfgBitMap() { + // Find non-exported ntdll!LdrSystemDllInitBlock.CfgBitMap by looking at the first instruction of LdrControlFlowGuardEnforced + // 48833d [80be1400], 00 CMP qword ptr[LdrSystemDllInitBlock.CfgBitMap], 0x0 +#pragma warning(suppress: 6387) // ntdll is always loaded + auto pLdrControlFlowGuardEnforced = GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "LdrControlFlowGuardEnforced"); + if (NULL == pLdrControlFlowGuardEnforced) + return NULL; + + auto ppCfgBitMap = (PULONG_PTR*)((ULONG_PTR)pLdrControlFlowGuardEnforced + 8 + *(DWORD*)((ULONG_PTR)pLdrControlFlowGuardEnforced + 3)); + + MEMORY_BASIC_INFORMATION mbi; + if (!VirtualQuery(*ppCfgBitMap, &mbi, sizeof(mbi)) || MEM_MAPPED != mbi.Type || *ppCfgBitMap != mbi.AllocationBase) { + LogError("FATAL: ntdll!LdrSystemDllInitBlock.CfgBitMap not found\n"); + ExitProcess(1); + } + + return *ppCfgBitMap; +} + +constexpr auto CFG_INVALID = 0b00; // no address in this range is a valid target +constexpr auto CFG_ALL_VALID = 0b11; // all addresses in this range are valid. +constexpr auto CFG_ALIGNED_VALID = 0b01; // the only valid target is 16-byte aligned +constexpr auto CFG_EXPORT_SUPPRESSED = 0b10; // this range contains an export-suppressed target + +BOOL GetCfgBitsForAddress(PVOID address, PULONG pCfgBits) { + static auto pCfgBitMap = FindCfgBitMap(); + const PULONG_PTR pLocalEntry = pCfgBitMap + ((ULONG_PTR)address >> 9); + const ULONG cfgOffset = (((ULONG_PTR)(address)) >> 3) & 0x3E; + ULONG_PTR localEntry; + // We use ReadProcessMemory to safely read the volatile CfgBitMap. + if (ReadProcessMemory(GetCurrentProcess(), pLocalEntry, &localEntry, sizeof(localEntry), NULL)) { + *pCfgBits = (localEntry >> cfgOffset) & 0b11; + return TRUE; + } + return FALSE; +} diff --git a/Get-InjectedThreadEx/Get-InjectedThreadEx.h b/Get-InjectedThreadEx/Get-InjectedThreadEx.h new file mode 100644 index 0000000..022a101 --- /dev/null +++ b/Get-InjectedThreadEx/Get-InjectedThreadEx.h @@ -0,0 +1,89 @@ +#pragma once + +#pragma comment(lib, "ntdll.lib") +#pragma comment(lib, "DbgHelp.lib") +#pragma comment(lib, "Zydis.lib") + +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +//#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// x86/x86-64 disassembler +// https://github.com/zyantific/zydis - MIT +#define ZYDIS_STATIC_BUILD +#include + +// https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation +constexpr auto MAX_LONG_PATH = 0x7FFF; + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Logging +#define Log(_fmt_, ...) printf(_fmt_ "\n", ##__VA_ARGS__) +#define LogError(_fmt_, ...) fprintf(stderr, "[!] " _fmt_ "\n", ##__VA_ARGS__) +#if _DEBUG +#define LogDebug(_fmt_, ...) printf("[#] " _fmt_ "\n", ##__VA_ARGS__) +#else +#define LogDebug(...) +#endif + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Native API +#define SE_DEBUG_PRIVILEGE (20L) +typedef LONG NTSTATUS; +#define NT_SUCCESS(Status) ((Status) >= 0) +extern "C" { + NTSTATUS RtlAdjustPrivilege(ULONG Privilege, BOOLEAN Enable, BOOLEAN CurrentThread, PBOOLEAN Enabled); +} + + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// CfgBitMap.cpp +BOOL GetCfgBitsForAddress(PVOID address, PULONG pCfgBits); +BOOL IsValidXfgHash(UINT64 xfgHash); + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Memory.cpp +std::string ToHex(std::string bytes); +BOOL IsExecutable(const MEMORY_BASIC_INFORMATION& mbi); +BOOL InSystemImageRange(PVOID Address); +BOOL ReadProcessMemorySafely(HANDLE hProcess, PVOID address, PDWORD64 buffer); +BOOL ReadProcessMemorySafely(HANDLE hProcess, PVOID address, std::string& buffer); +BOOL ReadProcessMemorySafely(HANDLE hProcess, PVOID address, std::string& buffer, const MEMORY_BASIC_INFORMATION& mbi); +BOOL GetMappedFileNameAsDosPath(HANDLE hProcess, PVOID address, std::wstring& buffer); + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Process.cpp +BOOL IsElevated(); +BOOL IsDotNet(HANDLE hProcess); + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// StackClimb.cpp +constexpr auto MIN_FRAMES = 5; +BOOL StackClimb64(const HANDLE hProcess, const PVOID stackBuffer[], const size_t stackBufferCount, std::vector& callStackFrames, bool* pbDetection, int offset = 0); + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Symbol.cpp +BOOL GetNearestSymbol(HANDLE hProcess, PVOID address, std::string& symbol, bool bIncludeDisplacement = false); +BOOL GetNearestSymbolWithPdb(HANDLE hProcess, PVOID address, std::string& symbol, bool bIncludeDisplacement = false); +PVOID GetSymbolAddress(const char symbol[]); + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Unwind.cpp +HRESULT CalculateFrameSize(PVOID returnAddress, PDWORD pFrameSize, const std::wstring& filepath, PVOID remoteBase); +BOOL IsValidCallSite(HANDLE hProcess, bool bIsWow64, const MEMORY_BASIC_INFORMATION& mbi, PVOID callsite, bool* bCallFound); + +int main(int, char* []); diff --git a/Get-InjectedThreadEx/Get-InjectedThreadEx.sln b/Get-InjectedThreadEx/Get-InjectedThreadEx.sln new file mode 100644 index 0000000..620130c --- /dev/null +++ b/Get-InjectedThreadEx/Get-InjectedThreadEx.sln @@ -0,0 +1,142 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33627.172 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Get-InjectedThreadEx", "Get-InjectedThreadEx.vcxproj", "{256D0782-6D09-44B2-AD5C-FC8EF085D1D3}" + ProjectSection(ProjectDependencies) = postProject + {88A23124-5640-35A0-B890-311D7A67A7D2} = {88A23124-5640-35A0-B890-311D7A67A7D2} + EndProjectSection +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Zydis", "zydis\msvc\zydis\Zydis.vcxproj", "{88A23124-5640-35A0-B890-311D7A67A7D2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug Kernel|x64 = Debug Kernel|x64 + Debug Kernel|x86 = Debug Kernel|x86 + Debug MD DLL|x64 = Debug MD DLL|x64 + Debug MD DLL|x86 = Debug MD DLL|x86 + Debug MD|x64 = Debug MD|x64 + Debug MD|x86 = Debug MD|x86 + Debug MT DLL|x64 = Debug MT DLL|x64 + Debug MT DLL|x86 = Debug MT DLL|x86 + Debug MT|x64 = Debug MT|x64 + Debug MT|x86 = Debug MT|x86 + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release Kernel|x64 = Release Kernel|x64 + Release Kernel|x86 = Release Kernel|x86 + Release MD DLL|x64 = Release MD DLL|x64 + Release MD DLL|x86 = Release MD DLL|x86 + Release MD|x64 = Release MD|x64 + Release MD|x86 = Release MD|x86 + Release MT DLL|x64 = Release MT DLL|x64 + Release MT DLL|x86 = Release MT DLL|x86 + Release MT|x64 = Release MT|x64 + Release MT|x86 = Release MT|x86 + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug Kernel|x64.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug Kernel|x64.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug Kernel|x86.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug Kernel|x86.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MD DLL|x64.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MD DLL|x64.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MD DLL|x86.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MD DLL|x86.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MD|x64.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MD|x64.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MD|x86.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MD|x86.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MT DLL|x64.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MT DLL|x64.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MT DLL|x86.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MT DLL|x86.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MT|x64.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MT|x64.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MT|x86.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug MT|x86.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug|x64.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug|x64.Build.0 = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Debug|x86.ActiveCfg = Debug|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release Kernel|x64.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release Kernel|x64.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release Kernel|x86.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release Kernel|x86.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MD DLL|x64.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MD DLL|x64.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MD DLL|x86.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MD DLL|x86.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MD|x64.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MD|x64.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MD|x86.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MD|x86.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MT DLL|x64.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MT DLL|x64.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MT DLL|x86.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MT DLL|x86.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MT|x64.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MT|x64.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MT|x86.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release MT|x86.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release|x64.ActiveCfg = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release|x64.Build.0 = Release|x64 + {256D0782-6D09-44B2-AD5C-FC8EF085D1D3}.Release|x86.ActiveCfg = Release|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug Kernel|x64.ActiveCfg = Debug Kernel|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug Kernel|x64.Build.0 = Debug Kernel|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug Kernel|x86.ActiveCfg = Debug Kernel|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug Kernel|x86.Build.0 = Debug Kernel|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MD DLL|x64.ActiveCfg = Debug MD DLL|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MD DLL|x64.Build.0 = Debug MD DLL|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MD DLL|x86.ActiveCfg = Debug MD DLL|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MD DLL|x86.Build.0 = Debug MD DLL|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MD|x64.ActiveCfg = Debug MD|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MD|x64.Build.0 = Debug MD|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MD|x86.ActiveCfg = Debug MD|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MD|x86.Build.0 = Debug MD|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MT DLL|x64.ActiveCfg = Debug MT DLL|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MT DLL|x64.Build.0 = Debug MT DLL|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MT DLL|x86.ActiveCfg = Debug MT DLL|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MT DLL|x86.Build.0 = Debug MT DLL|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MT|x64.ActiveCfg = Debug MT|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MT|x64.Build.0 = Debug MT|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MT|x86.ActiveCfg = Debug MT|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug MT|x86.Build.0 = Debug MT|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug|x64.ActiveCfg = Debug MD|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug|x64.Build.0 = Debug MD|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug|x86.ActiveCfg = Debug MD|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Debug|x86.Build.0 = Debug MD|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release Kernel|x64.ActiveCfg = Release Kernel|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release Kernel|x64.Build.0 = Release Kernel|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release Kernel|x86.ActiveCfg = Release Kernel|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release Kernel|x86.Build.0 = Release Kernel|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MD DLL|x64.ActiveCfg = Release MD DLL|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MD DLL|x64.Build.0 = Release MD DLL|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MD DLL|x86.ActiveCfg = Release MD DLL|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MD DLL|x86.Build.0 = Release MD DLL|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MD|x64.ActiveCfg = Release MD|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MD|x64.Build.0 = Release MD|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MD|x86.ActiveCfg = Release MD|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MD|x86.Build.0 = Release MD|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MT DLL|x64.ActiveCfg = Release MT DLL|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MT DLL|x64.Build.0 = Release MT DLL|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MT DLL|x86.ActiveCfg = Release MT DLL|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MT DLL|x86.Build.0 = Release MT DLL|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MT|x64.ActiveCfg = Release MT|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MT|x64.Build.0 = Release MT|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MT|x86.ActiveCfg = Release MT|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release MT|x86.Build.0 = Release MT|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release|x64.ActiveCfg = Release MD DLL|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release|x64.Build.0 = Release MD DLL|x64 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release|x86.ActiveCfg = Release MT DLL|Win32 + {88A23124-5640-35A0-B890-311D7A67A7D2}.Release|x86.Build.0 = Release MT DLL|Win32 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2409743E-DDE1-4665-8C29-FA5C5C8DEEFE} + EndGlobalSection +EndGlobal diff --git a/Get-InjectedThreadEx/Get-InjectedThreadEx.vcxproj b/Get-InjectedThreadEx/Get-InjectedThreadEx.vcxproj new file mode 100644 index 0000000..6ede375 --- /dev/null +++ b/Get-InjectedThreadEx/Get-InjectedThreadEx.vcxproj @@ -0,0 +1,106 @@ + + + + + Debug + x64 + + + Release + x64 + + + + + + + + + + + + + + + + 16.0 + Win32Proj + {256d0782-6d09-44b2-ad5c-fc8ef085d1d3} + GetInjectedThreadEx + 10.0 + + + + Application + true + v142 + Unicode + + + Application + false + v143 + true + Unicode + + + + + + + + + + + + + + + $(SolutionDir)\phnt;$(SolutionDir)\zydis\include;$(SolutionDir)\zydis\dependencies\zycore\include;$(IncludePath) + $(SolutionDir)\zydis\msvc\bin\$(Configuration)$(Platform);$(LibraryPath) + + + $(SolutionDir)\phnt;$(SolutionDir)\zydis\include;$(SolutionDir)\zydis\dependencies\zycore\include;$(IncludePath) + $(SolutionDir)\zydis\msvc\bin\$(Configuration)$(Platform);$(LibraryPath) + + + + Level3 + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdcpp20 + stdc17 + Guard + ProgramDatabase + pch.h + + + Console + true + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdcpp20 + stdc17 + Guard + pch.h + + + Console + true + true + true + + + + + + \ No newline at end of file diff --git a/Get-InjectedThreadEx/Get-InjectedThreadEx.vcxproj.filters b/Get-InjectedThreadEx/Get-InjectedThreadEx.vcxproj.filters new file mode 100644 index 0000000..6dfcb56 --- /dev/null +++ b/Get-InjectedThreadEx/Get-InjectedThreadEx.vcxproj.filters @@ -0,0 +1,45 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + Header Files + + + \ No newline at end of file diff --git a/Get-InjectedThreadEx/Memory.cpp b/Get-InjectedThreadEx/Memory.cpp new file mode 100644 index 0000000..95d889b --- /dev/null +++ b/Get-InjectedThreadEx/Memory.cpp @@ -0,0 +1,114 @@ +#include "Get-InjectedThreadEx.h" + +BOOL IsExecutable(const MEMORY_BASIC_INFORMATION& mbi) { + constexpr auto PAGE_EXECUTE_ANY = PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + return MEM_COMMIT == mbi.State && 0 != (mbi.Protect & PAGE_EXECUTE_ANY); +} + +BOOL ReadProcessMemorySafely(HANDLE hProcess, PVOID address, std::string& buffer, const MEMORY_BASIC_INFORMATION& mbi) { + if (MEM_COMMIT != mbi.State || 0 != (mbi.Protect & PAGE_GUARD)) + return FALSE; + // TODO(jdu) NEXT handle reads split over multiple regions + return ReadProcessMemory(hProcess, address, buffer.data(), buffer.size(), NULL); +} + +BOOL ReadProcessMemorySafely(HANDLE hProcess, PVOID address, std::string& buffer) { + MEMORY_BASIC_INFORMATION mbi; + if(!VirtualQueryEx(hProcess, address, &mbi, sizeof(mbi))) + return FALSE; + return ReadProcessMemorySafely(hProcess, address, buffer, mbi); +} + +BOOL ReadProcessMemorySafely(HANDLE hProcess, PVOID address, PDWORD64 buffer) { + MEMORY_BASIC_INFORMATION mbi; + if (!VirtualQueryEx(hProcess, address, &mbi, sizeof(mbi))) + return FALSE; + auto b = MEM_COMMIT != mbi.State; + auto b2 = 0 != (mbi.Protect & PAGE_GUARD); + auto b3 = (MEM_COMMIT != mbi.State || 0 != (mbi.Protect & PAGE_GUARD)); + if (MEM_COMMIT != mbi.State || 0 != (mbi.Protect & PAGE_GUARD)) + return FALSE; + return ReadProcessMemory(hProcess, address, buffer, sizeof(DWORD64), NULL); +} + +BOOL GetMappedFileNameAsDosPath(HANDLE hProcess, PVOID address, std::wstring& buffer) { + + // Cache #1 - the mapping of shared image allocations to DOS paths + const auto bIsSharedDll = InSystemImageRange(address); + const auto allocationBase = (ULONG_PTR)address & ~0xFFFFull; + static std::unordered_map s_DosPathCache; + if (bIsSharedDll) { + const auto it = s_DosPathCache.find(allocationBase); + if (it != s_DosPathCache.end()) { + buffer = it->second; + return TRUE; + } + } + + // Cache #2 - the mapping from Device prefixes to Dos prefixes + static std::unordered_map s_Device2DosPrefixMap; + if (s_Device2DosPrefixMap.empty()) { + wchar_t drive[3] = L"A:"; + for (drive[0] = L'A'; drive[0] <= L'Z'; drive[0]++) { + std::wstring deviceName; + deviceName.resize(MAX_PATH); + if (!QueryDosDeviceW(drive, deviceName.data(), (DWORD)deviceName.size())) { + const auto dwError = GetLastError(); + if (ERROR_FILE_NOT_FOUND != dwError) + LogError("QueryDosDeviceW(%S) failed with %d\n", drive, dwError); + continue; + } + deviceName.resize(wcslen(deviceName.c_str())); + s_Device2DosPrefixMap.insert(std::make_pair(deviceName, std::wstring(drive))); + } + } + + buffer.clear(); + buffer.resize(MAX_PATH); + + // Note - K32GetMappedFileName returns the device path such as \Device\Harddisk0\Windows\System32\ntdll.dll + auto nBytesReturned = K32GetMappedFileNameW(hProcess, address, buffer.data(), (DWORD)buffer.size()); + if (MAX_PATH == nBytesReturned) { + buffer.resize(MAX_LONG_PATH); + nBytesReturned = K32GetMappedFileNameW(hProcess, address, buffer.data(), (DWORD)buffer.size()); + if (MAX_LONG_PATH == nBytesReturned) + LogError("K32GetMappedFileNameW(%p) exceeded maximum long path - %S\n", address, buffer.substr(0, MAX_PATH).c_str()); + } + + if (0 == nBytesReturned) + return FALSE; + + // We have a device path - now convert it to a DOS path + constexpr auto DEVICE_PREFIX = L"\\Device\\"; + if (buffer.length() < sizeof(DEVICE_PREFIX) || 0 != _wcsnicmp(buffer.c_str(), DEVICE_PREFIX, sizeof(DEVICE_PREFIX))) { + LogError("K32GetMappedFileNameW(%p) did not return device path - %S\n", address, buffer.substr(0, MAX_PATH).c_str()); + return TRUE; // best effort - return what we have + } + + // Loopkup DOS prefix of "\device\" component in our cached mapping + const auto pos = buffer.find(L'\\', std::char_traits::length(DEVICE_PREFIX)); + const auto devicePrefixLength = (std::wstring::npos == pos) ? buffer.length() : pos; + const auto device2DosPrefixMapping = s_Device2DosPrefixMap.find(buffer.substr(0, devicePrefixLength)); + if (s_Device2DosPrefixMap.end() == device2DosPrefixMapping) { + LogError("Could not resolve device prefix to DOS drive letter - %S\n", buffer.substr(0, MAX_PATH).c_str()); + return TRUE; // best effort - return what we have + } + + buffer = device2DosPrefixMapping->second + buffer.substr(devicePrefixLength); + buffer.resize(wcslen(buffer.c_str())); + + // Update our path cache + if (nBytesReturned && bIsSharedDll) + s_DosPathCache.insert(std::make_pair(allocationBase, buffer)); + + return TRUE; +} + +std::string ToHex(std::string bytes) { + std::stringstream hexBytes; + hexBytes << std::hex << std::setfill('0'); + for (size_t i = 0; i < bytes.length(); i++) { + hexBytes << std::setw(2) << (DWORD)(BYTE)bytes[i]; + } + return hexBytes.str(); +} \ No newline at end of file diff --git a/Get-InjectedThreadEx/Process.cpp b/Get-InjectedThreadEx/Process.cpp new file mode 100644 index 0000000..2280652 --- /dev/null +++ b/Get-InjectedThreadEx/Process.cpp @@ -0,0 +1,68 @@ +#include "Get-InjectedThreadEx.h" + +BOOL IsElevated() +{ + BOOL bIsElevated = FALSE; + + DWORD dwSize; + HANDLE hToken = NULL; + TOKEN_ELEVATION elevation; + + if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hToken) && + GetTokenInformation(hToken, TokenElevation, &elevation, sizeof(elevation), &dwSize) && + sizeof(elevation) == dwSize) + { + bIsElevated = elevation.TokenIsElevated; + } + + if (hToken) { + (void)CloseHandle(hToken); + } + + return bIsElevated; +} + +BOOL GetProcessPath(HANDLE hProcess, std::wstring& buffer) { + buffer.clear(); + + buffer.resize(MAX_PATH); + DWORD size = (DWORD)buffer.size(); + if (!QueryFullProcessImageNameW(hProcess, 0, buffer.data(), &size)) { + buffer.resize(MAX_LONG_PATH); + size = (DWORD)buffer.size(); + if (!QueryFullProcessImageNameW(hProcess, 0, buffer.data(), &size)) { + buffer.clear(); + return FALSE; + } + } + + buffer.resize(size); + return TRUE; +} + +BOOL IsDotNet(HANDLE hProcess) { + std::wstring executable; + if (!GetProcessPath(hProcess, executable)) + return FALSE; + + // Map the executable's PE headers. + constexpr auto FILE_SHARE_ALL = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; + auto hFile = CreateFileW(executable.c_str(), GENERIC_READ, FILE_SHARE_ALL, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + auto hMapping = CreateFileMappingW(hFile, NULL, PAGE_READONLY, 0, 0, NULL); + auto pMapping = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0x1000); + + // https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#optional-header-data-directories-image-only + // The last directory is actually the CLR Runtime Header - not the "COM Descriptor". + // If it exists, then the PE file is a CLR assembly - aka .NET + ULONG size = 0; + BOOL bIsDotNet = NULL != ImageDirectoryEntryToData(pMapping, TRUE, IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR, &size); + + if (pMapping) + (void)UnmapViewOfFile(pMapping); + if (hMapping) + (void)CloseHandle(hMapping); + if (hFile) + (void)CloseHandle(hFile); + + return bIsDotNet; +} diff --git a/Get-InjectedThreadEx/StackClimb.cpp b/Get-InjectedThreadEx/StackClimb.cpp new file mode 100644 index 0000000..2b6cafc --- /dev/null +++ b/Get-InjectedThreadEx/StackClimb.cpp @@ -0,0 +1,118 @@ +#include "Get-InjectedThreadEx.h" + + +void SymboliseCallStack(HANDLE hProcess, const std::vector returnAddresses, const std::string last, std::vector &callStackFrames) { + for (const auto address : returnAddresses) { + std::string frameSymbol; + if (!GetNearestSymbolWithPdb(hProcess, address, frameSymbol)) + frameSymbol = ""; + callStackFrames.push_back(frameSymbol); + } + callStackFrames.push_back(last); +} + +BOOL StackClimb64(const HANDLE hProcess, + const PVOID stackBuffer[], + const size_t stackBufferCount, + std::vector& callStackFrames, + bool* pbDetection, + int offset) +{ + std::vector returnAddresses; + + // Query SystemInfo for the usermode address bounds + static SYSTEM_INFO s_si{}; + if (!s_si.lpMaximumApplicationAddress) { + GetSystemInfo(&s_si); + if (0 == s_si.lpMaximumApplicationAddress) { + LogError("GetSystemInfo() failed"); + return FALSE; + } + + // Note - My code makes some assumptions about page and allocation sizes. + if (0x1000 != s_si.dwPageSize || 0x10000 != s_si.dwAllocationGranularity) { + LogError("System not supported. PageSize=0x%x AllocationGranularity=0x%x", s_si.dwPageSize, s_si.dwAllocationGranularity); + return FALSE; + } + } + + auto lastRspOffset = 0u; + const auto StackBufferLast = &stackBuffer[stackBufferCount - 1]; + for (auto i = offset; !*pbDetection && (i < stackBufferCount) && (returnAddresses.size() < MIN_FRAMES); i += 2) { + const PVOID candidateRip = StackBufferLast[-i]; + + // Skip any invalid usermode addresses + if (candidateRip < s_si.lpMinimumApplicationAddress || candidateRip >= s_si.lpMaximumApplicationAddress) + continue; + + MEMORY_BASIC_INFORMATION mbi; + if (!VirtualQueryEx(hProcess, candidateRip, &mbi, sizeof(mbi))) + return FALSE; + + // Return address must be executable + if (!IsExecutable(mbi)) + continue; + + // We can't read this executable page due to the Guard - so just alert. + if (0 != (mbi.State & PAGE_GUARD)) { + SymboliseCallStack(hProcess, returnAddresses, "GUARD", callStackFrames); + *pbDetection = true; + return TRUE; + } + + bool bValidCallsite; + if (MEM_IMAGE != mbi.Type) { + if (IsValidCallSite(hProcess, false, mbi, candidateRip, &bValidCallsite) && bValidCallsite) { + // A suspicious frame was found! + SymboliseCallStack(hProcess, returnAddresses, "PRIVATE", callStackFrames); + *pbDetection = true; + return TRUE; + } + continue; // Not a return address - keep searching + } + + std::wstring candidateRipMappedPath; + if (!GetMappedFileNameAsDosPath(hProcess, candidateRip, candidateRipMappedPath)) + return FALSE; + + DWORD frameSize = 0; + if (0 != lastRspOffset) { + auto status = CalculateFrameSize(candidateRip, &frameSize, candidateRipMappedPath, mbi.AllocationBase); + if (S_OK == status && (i - lastRspOffset) != frameSize) + continue; // Invalid frame size - keep searching + + if (IS_ERROR(status)) // but not WARNINGs + continue; + + // We could not calculate exact frame size - but do have a lowerbound. + // TODO(jdu) We could scan ahead one (or more) frames here to see if we recover? + if (S_OK != status && (i - lastRspOffset) < frameSize) + continue; // Frame too small - keep searching + } + + if (!IsValidCallSite(hProcess, false, mbi, candidateRip, &bValidCallsite) && !bValidCallsite) + continue; // Not a return address - keep searching + + auto status = CalculateFrameSize(candidateRip, &frameSize, candidateRipMappedPath, mbi.AllocationBase); + // LogDebug(" %03i: %p %s valid=%d frameSize=%d FP=%d", i, candidateRip, frameSymbol.c_str(), bValidCallsite, frameSize, S_OK != status); + returnAddresses.push_back(candidateRip); + + lastRspOffset = i; + + // Frames must be at least 0x20 bytes due to fastcall parameter shadow space + i += 4; + } + + // Just use exports if nothing suspicious was found + // TODO(jdu) We only need to return this at all so that we know if a 2nd pass is required. + // Perhaps move 2nd pass and RIP logic here. + if (callStackFrames.empty()) { + for (const auto address : returnAddresses) { + std::string frameSymbol; + (void)GetNearestSymbol(hProcess, address, frameSymbol); + callStackFrames.push_back(frameSymbol); + } + } + + return TRUE; +} \ No newline at end of file diff --git a/Get-InjectedThreadEx/Symbol.cpp b/Get-InjectedThreadEx/Symbol.cpp new file mode 100644 index 0000000..4364f91 --- /dev/null +++ b/Get-InjectedThreadEx/Symbol.cpp @@ -0,0 +1,154 @@ +#include "Get-InjectedThreadEx.h" + +typedef struct _SYMBOL_INFO_FULL : SYMBOL_INFO { + CHAR NameBuffer[MAX_SYM_NAME - 1]; +} SYMBOL_INFO_FULL; +#define INIT_FAILED ((HANDLE)-3) + +constexpr auto SYMOPTS = SYMOPT_UNDNAME | SYMOPT_CASE_INSENSITIVE | SYMOPT_FAIL_CRITICAL_ERRORS | SYMOPT_INCLUDE_32BIT_MODULES; + +constexpr auto MicrosoftPublicSymbols = L"srv**https://msdl.microsoft.com/download/symbols"; + +static HANDLE GetUniqueIdForSymbols() { + // Initialise once - note we're only resolving symbols locally. + static HANDLE s_hUniqueId = NULL; // INVALID_HANDLE_VALUE == GetCurrentProcess()! + if (NULL == s_hUniqueId) { + if (!SymSetOptions(SymGetOptions() | SYMOPTS)) + LogError("SymSetOptions() failed with 0x%x", GetLastError()); + s_hUniqueId = GetCurrentProcess(); + if (!SymInitializeW(s_hUniqueId, MicrosoftPublicSymbols, FALSE)) { + LogError("SymInitialize() failed with 0x%x", GetLastError()); + s_hUniqueId = INIT_FAILED; + } + else + { + // Check that we're using public symbols - not just exports + auto hNtdll = GetModuleHandleW(L"ntdll.dll"); + std::wstring ntdllPath; + if (!GetMappedFileNameAsDosPath(GetCurrentProcess(), hNtdll, ntdllPath)) + LogError("GetMappedFilename(ntdll) failed"); + if(!SymLoadModuleExW(s_hUniqueId, NULL, ntdllPath.c_str(), NULL, (DWORD64)hNtdll, 0, NULL, 0)) + LogError("SymLoadModule(ntdll) failed"); + if (!GetSymbolAddress("ntdll!TppWorkerThread")) + LogError("WARNING Symbols not found - falling back to exports"); + + auto hKernel32 = GetModuleHandleW(L"kernel32.dll"); + std::wstring kernel32Path; + if (!GetMappedFileNameAsDosPath(GetCurrentProcess(), hKernel32, kernel32Path)) + LogError("GetMappedFilename(kernel32) failed"); + if (!SymLoadModuleExW(s_hUniqueId, NULL, kernel32Path.c_str(), NULL, (DWORD64)hKernel32, 0, NULL, 0)) + LogError("SymLoadModule(kernel32) failed"); + } + } + return s_hUniqueId; +} + +PVOID GetSymbolAddress(const char symbol[]) { + const auto hUniqueId = GetUniqueIdForSymbols(); + if (INIT_FAILED == hUniqueId) + return NULL; + + SYMBOL_INFO_FULL symbolInfo{}; + symbolInfo.SizeOfStruct = sizeof(SYMBOL_INFO); + symbolInfo.MaxNameLen = MAX_SYM_NAME; + if (!SymFromName(hUniqueId, symbol, &symbolInfo)) + return NULL; + + return (PVOID)symbolInfo.Address; +} + + +// pretty print ![+] +void GetPrettySymbol(HANDLE hProcess, PVOID address, std::wstring& modulePath, std::string& symbol, bool bIncludeDisplacement) { + symbol.resize(128); + symbol = std::filesystem::path(modulePath).filename().string(); + + SYMBOL_INFO_FULL symbolInfo{}; + symbolInfo.SizeOfStruct = sizeof(SYMBOL_INFO); + symbolInfo.MaxNameLen = MAX_SYM_NAME; + DWORD64 displacement = 0; + if (SymFromAddr(hProcess, (DWORD64)address, &displacement, &symbolInfo)) { + symbol += "!" + std::string(symbolInfo.Name); + } + else if (bIncludeDisplacement) { + MEMORY_BASIC_INFORMATION mbi; + if (VirtualQueryEx(hProcess, address, &mbi, sizeof(mbi))) + displacement = (DWORD64)address - (DWORD64)mbi.AllocationBase; + } + + if (bIncludeDisplacement && displacement) { + char buffer[_MAX_U64TOSTR_BASE16_COUNT]; + _ui64toa_s(displacement, buffer, sizeof(buffer), 16); + symbol += "+0x" + std::string(buffer); + } + + symbol.resize(strlen(symbol.c_str())); +} + +BOOL GetNearestSymbolWithPdb(HANDLE hProcess, PVOID address, std::string& symbol, bool bIncludeDisplacement) { + if (!SymSetOptions(SymGetOptions() | SYMOPTS)) + LogError("SymSetOptions() failed with 0x%x", GetLastError()); + + if (!SymInitializeW(hProcess, MicrosoftPublicSymbols, FALSE)) { + LogError("SymInitialize() failed with 0x%x", GetLastError()); + return FALSE; + } + + std::wstring modulePath; + if (!GetMappedFileNameAsDosPath(hProcess, address, modulePath)) { + if (symbol.empty()) { + char buffer[_MAX_U64TOSTR_BASE16_COUNT]; + _ui64toa_s((DWORD64)address, buffer, sizeof(buffer), 16); + symbol = "0x" + std::string(buffer); + } + (void)SymCleanup(hProcess); + return TRUE; + } + + MEMORY_BASIC_INFORMATION mbi; + if (!VirtualQueryEx(hProcess, address, &mbi, sizeof(mbi)) || + !SymLoadModuleExW(hProcess, NULL, modulePath.c_str(), NULL, (DWORD64)mbi.AllocationBase, 0, NULL, 0)) + { + (void)SymCleanup(hProcess); + // fallback to best-effort + const auto hUniqueId = GetUniqueIdForSymbols(); + GetPrettySymbol(hUniqueId, address, modulePath, symbol, bIncludeDisplacement); + return FALSE; + } + + GetPrettySymbol(hProcess, address, modulePath, symbol, bIncludeDisplacement); + + (void)SymCleanup(hProcess); + return TRUE; +} + + +BOOL GetNearestSymbol(HANDLE hProcess, PVOID address, std::string& symbol, bool bIncludeDisplacement) { + const auto hUniqueId = GetUniqueIdForSymbols(); + if (INIT_FAILED == hUniqueId) + return FALSE; + + // Maintain a cache of symbols in shared image allocations + static std::unordered_map s_SymbolCache; + const auto it = s_SymbolCache.find(address); + if (it != s_SymbolCache.end()) { + symbol = it->second; + return TRUE; + } + + // module name (from remote process) + std::wstring modulePath; + if (!GetMappedFileNameAsDosPath(hProcess, address, modulePath)) { + char buffer[_MAX_U64TOSTR_BASE16_COUNT]; + _ui64toa_s((DWORD64)address, buffer, sizeof(buffer), 16); + symbol = "0x" + std::string(buffer); + return TRUE; + } + + GetPrettySymbol(hUniqueId, address, modulePath, symbol, bIncludeDisplacement); + + if (InSystemImageRange(address)) + s_SymbolCache.insert(std::make_pair(address, symbol)); + + return TRUE; +} diff --git a/Get-InjectedThreadEx/Unwind.cpp b/Get-InjectedThreadEx/Unwind.cpp new file mode 100644 index 0000000..87b2edd --- /dev/null +++ b/Get-InjectedThreadEx/Unwind.cpp @@ -0,0 +1,296 @@ +// Reference: +// https://learn.microsoft.com/en-us/cpp/build/exception-handling-x64 +// +// Other useful references: +// https://codemachine.com/articles/x64_deep_dive.html +// http://www.uninformed.org/?v=4&a=1 Improving Automated Analysis of Windows x64 Binaries +// http://www.nynaeve.net/?p=113 Programming against the x64 exception handling support +// https://www.sciencedirect.com/science/article/pii/S1742287618300458 Building stack traces from memory dump of Windows x64 +// https://github.com/reactos/reactos/blob/master/sdk/lib/rtl/amd64/unwind.c +// https://auscitte.github.io/posts/Exception-Directory-pefile Boots for Walking Backwards: Teaching pefile How to Understand SEH-Related Data in 64-bit PE Files +// https://labs.withsecure.com/publications/spoofing-call-stacks-to-confuse-edrs + +#include "Get-InjectedThreadEx.h" + +typedef enum _UNWIND_OP_CODES { + UWOP_PUSH_NONVOL = 0, /* info == register number */ + UWOP_ALLOC_LARGE, /* no info, alloc size in next 2 slots */ + UWOP_ALLOC_SMALL, /* info == size of allocation / 8 - 1 */ + UWOP_SET_FPREG, /* no info, FP = RSP + UNWIND_INFO.FPRegOffset*16 */ + UWOP_SAVE_NONVOL, /* info == register number, offset in next slot */ + UWOP_SAVE_NONVOL_FAR, /* info == register number, offset in next 2 slots */ + UWOP_EPILOG, /* added in v2. UNDOCUMENTED. */ + UWOP_SAVE_XMM128 = 8, /* info == XMM reg number, offset in next slot */ + UWOP_SAVE_XMM128_FAR, /* info == XMM reg number, offset in next 2 slots */ + UWOP_PUSH_MACHFRAME /* info == 0: no dwError-code, 1: dwError-code */ +} UNWIND_CODE_OPS; + +// from ehdata.h +#pragma warning (push) +#pragma warning (disable: 4201) +typedef union _UNWIND_CODE { + struct { + unsigned char CodeOffset; + unsigned char UnwindOp : 4; + unsigned char OpInfo : 4; + }; + unsigned short FrameOffset; +} UNWIND_CODE, * PUNWIND_CODE; +typedef struct _UNWIND_INFO { + unsigned char Version : 3; + unsigned char Flags : 5; + unsigned char SizeOfProlog; + unsigned char CountOfCodes; + unsigned char FrameRegister : 4; + unsigned char FrameOffset : 4; + UNWIND_CODE UnwindCode[1]; + /* UNWIND_CODE MoreUnwindCode[((CountOfCodes+1)&~1)-1]; + * union { + * OPTIONAL unsigned long ExceptionHandler; + * OPTIONAL unsigned long FunctionEntry; + * }; + * OPTIONAL unsigned long ExceptionData[]; + */ +} UNWIND_INFO, * PUNWIND_INFO; +#pragma warning (pop) + +constexpr auto W_FPREG = 1; // WARNING - frame pointer in use; +static HRESULT CalculateFrameSize_Internal(const PRUNTIME_FUNCTION pRuntimeFunction, const DWORD64 ImageBase, const DWORD rva, PDWORD pFrameSize) +{ + HRESULT status = S_OK; + + const auto pUnwindInfo = (PUNWIND_INFO)(pRuntimeFunction->UnwindInfoAddress + ImageBase); + if (!pUnwindInfo || pUnwindInfo->Version > 2) { + LogError("UNWIND_INFO v%d not supported", pUnwindInfo->Version); + return E_NOTIMPL; + } + + const auto codeOffset = rva - pRuntimeFunction->BeginAddress; + + // Loop over unwind codes and calculate total stack space used by target function. + BYTE i = 0; + while (i < pUnwindInfo->CountOfCodes) + { + // Warning: This implementation is not complete and may not work for some edge cases. + // For example, it does not handle handle RIP pointing into an epilog. + // But that should be rare for our early frames use case. + + const auto unwindOperation = pUnwindInfo->UnwindCode[i].UnwindOp; + const auto operationInfo = pUnwindInfo->UnwindCode[i].OpInfo; + const auto bApplyOperation = codeOffset > pUnwindInfo->UnwindCode[i].CodeOffset; + + i++; + switch (unwindOperation) { + case UWOP_PUSH_NONVOL: + if (bApplyOperation) + (*pFrameSize)++; + break; + case UWOP_ALLOC_LARGE: + switch (operationInfo) { + case 0: + if (bApplyOperation) + *pFrameSize += pUnwindInfo->UnwindCode[i].FrameOffset; + i++; + break; + case 1: + if (bApplyOperation) + *pFrameSize += *(DWORD*)(&pUnwindInfo->UnwindCode[i]) / sizeof(PVOID); + i += 3; + break; + default: + LogError("UWOP_ALLOC_LARGE operationInfo is invalid: (%d)", operationInfo); + return E_INVALIDARG; + } + break; + case UWOP_ALLOC_SMALL: + if (bApplyOperation) + *pFrameSize += operationInfo + 1; + break; + case UWOP_SET_FPREG: + if (bApplyOperation) { + // Frame pointer in use. Our frame size calculation is inaccurate if alloca() was used. + // https://learn.microsoft.com/en-us/cpp/build/stack-usage + // > If space is dynamically allocated (alloca) in a function, then a + // > nonvolatile register must be used as a frame pointer to mark the + // > base of the fixed part of the stack and that register must be saved + // > and initialized in the prolog. + *pFrameSize = pUnwindInfo->FrameOffset * 2; + status = W_FPREG; // This is a WARNING code - not an ERROR + } + break; + case UWOP_SAVE_NONVOL: + case UWOP_SAVE_XMM128: + case UWOP_EPILOG: + i++; + break; + case UWOP_SAVE_NONVOL_FAR: + case UWOP_SAVE_XMM128_FAR: + i += 2; + break; + case UWOP_PUSH_MACHFRAME: + if (bApplyOperation) { + *pFrameSize += 5 + operationInfo; + } + break; + default: + if (bApplyOperation) { + LogError("UNWIND_INFO operation is not implemented: %d", unwindOperation); + return E_INVALIDARG; + } + } + } + + if (0 != (UNW_FLAG_CHAININFO & pUnwindInfo->Flags)) { + const auto pPrimaryUwindInfo = (PRUNTIME_FUNCTION) & (pUnwindInfo->UnwindCode[(pUnwindInfo->CountOfCodes + 1) & ~1]); + return CalculateFrameSize_Internal(pPrimaryUwindInfo, ImageBase, rva, pFrameSize); + } + + // Add the size of the return address. + *pFrameSize += 1; + + return status; +} + +static_assert(!IS_ERROR(W_FPREG)); +static_assert(!FAILED(W_FPREG)); +// Calculates the total stack space (in PVOIDs) used by the stack frame. +HRESULT CalculateFrameSize(PVOID returnAddress, PDWORD pFrameSize, const std::wstring& filepath, PVOID remoteBase) +{ + // Check a cache of UNWIND_INFO lookups first. + static std::unordered_map frameSizes; + const auto it = frameSizes.find(returnAddress); + if (it != frameSizes.end()) { + *pFrameSize = it->second; + return S_OK; + } + + const DWORD rva = (DWORD)((ULONG_PTR)returnAddress - (ULONG_PTR)remoteBase); + PRUNTIME_FUNCTION pRuntimeFunction = NULL; + DWORD64 localBase; + + auto hModule = GetModuleHandleW(filepath.c_str()); + if (hModule == remoteBase) { + // Image is already loaded in our process - lock it in memory now. + hModule = LoadLibraryW(filepath.c_str()); + if (NULL == hModule) { + LogError("LoadLibrary(%S) failed", filepath.c_str()); + return E_UNEXPECTED; + + } + + pRuntimeFunction = RtlLookupFunctionEntry((DWORD64)returnAddress, &localBase, NULL); + if (NULL == pRuntimeFunction) + return E_FAIL; + } + else { + // Image is not loaded - so load it now. + // We don't want to execute anything malicious in our process - so map it now as a read-only resource. + hModule = LoadLibraryExW(filepath.c_str(), NULL, LOAD_LIBRARY_AS_IMAGE_RESOURCE | LOAD_LIBRARY_AS_DATAFILE); + if (NULL == hModule) { + LogDebug("LoadLibraryEx(%S, AS_IMAGE_RESOURCE) failed", filepath.c_str()); + return E_UNEXPECTED; + } + localBase = (DWORD64)hModule & ~0xFFFF; // The lower bits of resource-only module handles are used as flags. + + // Being resource-only means that we can't use the usual APIs. + // Instead, manually walk the UNWIND_INFO in the exception directory of the image. + ULONG indexLo = 0; + ULONG indexHi; + auto pTable = (PRUNTIME_FUNCTION)ImageDirectoryEntryToData((PVOID)localBase, TRUE, IMAGE_DIRECTORY_ENTRY_EXCEPTION, &indexHi); + if (!pTable) { + (void)FreeLibrary(hModule); + const auto error = GetLastError(); + if (ERROR_SUCCESS != error) + LogError("ImageDirectoryEntryToData(%S) failed with %d", filepath.c_str(), error); + else + LogDebug("ImageDirectoryEntryToData(%S) - Exception Directory not found"); + return E_FAIL; + } + + indexHi = indexHi / sizeof(RUNTIME_FUNCTION); + while (indexHi > indexLo) { + const ULONG indexMid = (indexLo + indexHi) / 2; + pRuntimeFunction = &pTable[indexMid]; + if (rva < pRuntimeFunction->BeginAddress) { + indexHi = indexMid; // search lower + pRuntimeFunction = NULL; + continue; + } + if (rva >= pRuntimeFunction->EndAddress) { + indexLo = indexMid + 1; // search higher + pRuntimeFunction = NULL; + continue; + } + break; // found + } + } + + if (!pRuntimeFunction) { + (void)FreeLibrary(hModule); + return E_FAIL; // leaf functions can't call further functions + } + + *pFrameSize = 0; + auto status = CalculateFrameSize_Internal(pRuntimeFunction, localBase, rva, pFrameSize); + if (S_OK == status) + frameSizes.insert(std::make_pair(returnAddress, *pFrameSize)); + + if (!FreeLibrary(hModule)) + LogError("FreeLibrary(%S) failed", filepath.c_str()); + + return status; +} + +BOOL IsValidCallSite( + HANDLE hProcess, + bool bIsWow64, + const MEMORY_BASIC_INFORMATION& mbi, + PVOID callsite, + bool* bCallFound) +{ + // Check a cache of valid callsites first + static std::unordered_set validCallSites; + if (validCallSites.contains(callsite)) { + *bCallFound = true; + return TRUE; + } + + // Otherwise read the preceding bytes and check for a valid call instruction + std::string precedingBytes{}; + precedingBytes.resize(std::min(11ull, (ULONG_PTR)callsite - (ULONG_PTR)mbi.BaseAddress)); + if(!ReadProcessMemorySafely(hProcess, (PVOID)((ULONG_PTR)callsite - precedingBytes.size()), precedingBytes, mbi)) + return FALSE; + + ZydisDecoder decoder; + if (bIsWow64) + (void)ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_COMPAT_32, ZYDIS_STACK_WIDTH_32); + else + (void)ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_64, ZYDIS_STACK_WIDTH_64); + + *bCallFound = false; + ZydisDecodedInstruction instruction; + // call instructions are 2-11 bytes, but check some common lengths first + static const std::array callLengthSearchOrder = { 5, 6, 7, 2, 3, 4, 8, 9, 10, 11 }; + for (const auto& lengthToCheck : callLengthSearchOrder) { + if (lengthToCheck <= precedingBytes.size() && + ZYAN_SUCCESS(ZydisDecoderDecodeInstruction(&decoder, nullptr, precedingBytes.data() + precedingBytes.size() - lengthToCheck, lengthToCheck, &instruction)) && + lengthToCheck == instruction.length && + ZYDIS_MNEMONIC_CALL == instruction.mnemonic) + { + *bCallFound = true; + // Note - For direct calls, we could (attempt to) improve this check by validating + // that the call target matches the return address. + // Though we would need to handle an Tail Call Optimised (TCO) functions... + // + // In practice this additional validation isn't required for our use case. + // False positivies are rare in early stack frames. + + // Cache the result + if (MEM_IMAGE == mbi.Type && InSystemImageRange(callsite)) + validCallSites.insert(callsite); + break; + } + } + + return TRUE; +} \ No newline at end of file diff --git a/Get-InjectedThreadEx/zydis b/Get-InjectedThreadEx/zydis new file mode 160000 index 0000000..a6d0c71 --- /dev/null +++ b/Get-InjectedThreadEx/zydis @@ -0,0 +1 @@ +Subproject commit a6d0c713b71b5009634868389f0ff551871273d6 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..11185a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2014-2021 Florian Bernd +Copyright (c) 2014-2021 Joel Höner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +