Repository: RetroAchievements/RAIntegration
Pull request #601 (2020-09-09 02:36:36)

add insecure memory checks


Patch:
diff --git a/src/Exports.cpp b/src/Exports.cpp
index c4b6938f..3a0d170d 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -99,6 +99,22 @@ static BOOL InitCommon([[maybe_unused]] HWND hMainHWND, [[maybe_unused]] int nEm
         });
     }
 
+    if (_RA_HardcoreModeIsActive())
+    {
+        const auto& pDesktop = dynamic_cast<ra::ui::win32::Desktop&>(ra::services::ServiceLocator::GetMutable<ra::ui::IDesktop>());
+        if (pDesktop.IsDebuggerPresent())
+        {
+            if (ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Disable Hardcore mode?",
+                L"A debugger or similar tool has been detected. If you do not disable hardcore mode, RetroAchievements functionality will be disabled.",
+                ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo) == ra::ui::DialogResult::No)
+            {
+                return FALSE;
+            }
+
+            ra::services::ServiceLocator::GetMutable<ra::data::EmulatorContext>().DisableHardcoreMode();
+        }
+    }
+
     return TRUE;
 }
 
diff --git a/src/data/EmulatorContext.cpp b/src/data/EmulatorContext.cpp
index b495ef7b..990b0441 100644
--- a/src/data/EmulatorContext.cpp
+++ b/src/data/EmulatorContext.cpp
@@ -10,6 +10,7 @@
 #include "data\GameContext.hh"
 #include "data\UserContext.hh"
 
+#include "services\IClock.hh"
 #include "services\IConfiguration.hh"
 #include "services\IFileSystem.hh"
 #include "services\IHttpRequester.hh"
@@ -717,5 +718,40 @@ void EmulatorContext::WriteMemory(ra::ByteAddress nAddress, MemSize nSize, uint3
     WriteMemoryByte(nAddress, nNewValue);
 }
 
+void EmulatorContext::ResetMemoryModified()
+{
+    m_bMemoryModified = false;
+
+    if (m_bMemoryInsecure)
+    {
+        // memory was insecure, immediately do a check
+        const auto& pDesktop = ra::services::ServiceLocator::Get<ra::ui::IDesktop>();
+        m_bMemoryInsecure = pDesktop.IsDebuggerPresent();
+
+        const auto& pClock = ra::services::ServiceLocator::Get<ra::services::IClock>();
+        m_tLastInsecureCheck = pClock.UpTime();
+    }
+}
+
+bool EmulatorContext::IsMemoryInsecure() const
+{
+    if (m_bMemoryInsecure)
+        return true;
+
+    // limit checks to once every 10 seconds
+    const auto& pClock = ra::services::ServiceLocator::Get<ra::services::IClock>();
+    const auto tNow = pClock.UpTime();
+    const auto tElapsed = tNow - m_tLastInsecureCheck;
+    if (tElapsed > std::chrono::seconds(10))
+    {
+        const auto& pDesktop = ra::services::ServiceLocator::Get<ra::ui::IDesktop>();
+        m_bMemoryInsecure = pDesktop.IsDebuggerPresent();
+        m_tLastInsecureCheck = tNow;
+    }
+
+    return m_bMemoryInsecure;
+}
+
+
 } // namespace data
 } // namespace ra
diff --git a/src/data/EmulatorContext.hh b/src/data/EmulatorContext.hh
index 4de42ab3..78d2c41c 100644
--- a/src/data/EmulatorContext.hh
+++ b/src/data/EmulatorContext.hh
@@ -222,7 +222,12 @@ public:
     /// <summary>
     /// Resets the memory modified flag.
     /// </summary>
-    void ResetMemoryModified() noexcept { m_bMemoryModified = false; }
+    void ResetMemoryModified();
+
+    /// <summary>
+    /// Determines if the memory is considered secure.
+    /// </summary>
+    virtual bool IsMemoryInsecure() const;
 
     class NotifyTarget
     {
@@ -275,6 +280,8 @@ protected:
     std::vector<MemoryBlock> m_vMemoryBlocks;
     size_t m_nTotalMemorySize = 0U;
     mutable bool m_bMemoryModified = false;
+    mutable bool m_bMemoryInsecure = false;
+    mutable std::chrono::steady_clock::time_point m_tLastInsecureCheck{};
 };
 
 } // namespace data
diff --git a/src/data/GameContext.cpp b/src/data/GameContext.cpp
index 555dac38..a720c5b6 100644
--- a/src/data/GameContext.cpp
+++ b/src/data/GameContext.cpp
@@ -784,6 +784,15 @@ void GameContext::AwardAchievement(ra::AchievementID nAchievementId) const
         bSubmit = false;
     }
 
+    if (bSubmit && _RA_HardcoreModeIsActive() && pEmulatorContext.IsMemoryInsecure())
+    {
+        vmPopup->SetTitle(L"Achievement NOT Unlocked");
+        vmPopup->SetErrorDetail(L"Error: RAM insecure");
+
+        bIsError = true;
+        bSubmit = false;
+    }
+
     int nPopupId = -1;
 
     if (pConfiguration.IsFeatureEnabled(ra::services::Feature::AchievementTriggeredNotifications))
@@ -1026,6 +1035,18 @@ void GameContext::SubmitLeaderboardEntry(ra::LeaderboardID nLeaderboardId, unsig
         return;
     }
 
+    if (pEmulatorContext.IsMemoryInsecure())
+    {
+        std::unique_ptr<ra::ui::viewmodels::PopupMessageViewModel> vmPopup(new ra::ui::viewmodels::PopupMessageViewModel);
+        vmPopup->SetTitle(L"Leaderboard NOT Submitted");
+        vmPopup->SetDescription(ra::Widen(pLeaderboard->Title()));
+        vmPopup->SetErrorDetail(L"Error: RAM insecure");
+
+        ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(L"Overlay\\info.wav");
+        ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueMessage(vmPopup);
+        return;
+    }
+
     ra::api::SubmitLeaderboardEntry::Request request;
     request.LeaderboardId = pLeaderboard->ID();
     request.Score = nScore;
diff --git a/src/data/SessionTracker.cpp b/src/data/SessionTracker.cpp
index 9b963200..1bf26f06 100644
--- a/src/data/SessionTracker.cpp
+++ b/src/data/SessionTracker.cpp
@@ -12,6 +12,7 @@
 #include "data\GameContext.hh"
 
 #include "services\IClock.hh"
+#include "services\IConfiguration.hh"
 #include "services\IFileSystem.hh"
 #include "services\ILocalStorage.hh"
 #include "services\IThreadPool.hh"
@@ -168,6 +169,14 @@ void SessionTracker::UpdateSession(time_t tSessionStart)
     // schedule next ping
     auto& pThreadPool = ra::services::ServiceLocator::GetMutable<ra::services::IThreadPool>();
     pThreadPool.ScheduleAsync(std::chrono::seconds(SERVER_PING_FREQUENCY), [this, tSessionStart]() { UpdateSession(tSessionStart); });
+
+    // check memory security
+    const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
+    if (pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore))
+    {
+        const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::EmulatorContext>();
+        pEmulatorContext.IsMemoryInsecure();
+    }
 }
 
 std::streampos SessionTracker::WriteSessionStats(std::chrono::seconds tSessionDuration) const
diff --git a/src/ui/IDesktop.hh b/src/ui/IDesktop.hh
index 63c3a879..b62c90d6 100644
--- a/src/ui/IDesktop.hh
+++ b/src/ui/IDesktop.hh
@@ -69,6 +69,11 @@ public:
     /// </summary>
     virtual std::unique_ptr<ra::ui::drawing::ISurface> CaptureClientArea(const WindowViewModelBase& vmViewModel) const = 0;
 
+    /// <summary>
+    /// Returns <c>true</c> if an external tool is detected that can modify memory.
+    /// </summary>
+    virtual bool IsDebuggerPresent() const = 0;
+
     virtual void Shutdown() = 0;
 
 protected:
diff --git a/src/ui/win32/Desktop.cpp b/src/ui/win32/Desktop.cpp
index df9a038a..e8473337 100644
--- a/src/ui/win32/Desktop.cpp
+++ b/src/ui/win32/Desktop.cpp
@@ -22,6 +22,8 @@
 
 #include "RA_Log.h"
 
+#include <TlHelp32.h>
+
 namespace ra {
 namespace ui {
 namespace win32 {
@@ -227,6 +229,68 @@ std::unique_ptr<ra::ui::drawing::ISurface> Desktop::CaptureClientArea(const Wind
     return pSurface;
 }
 
+static bool IsSuspiciousProcessRunning()
+{
+    bool bFound = false;
+
+    const HANDLE hSnapshot = ::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
+    if (hSnapshot)
+    {
+        PROCESSENTRY32 pe32;
+        pe32.dwSize = sizeof(PROCESSENTRY32);
+        if (Process32First(hSnapshot, &pe32))
+        {
+            do
+            {
+                if (strlen(pe32.szExeFile) >= 15)
+                {
+                    std::string sFilename = pe32.szExeFile;
+                    std::transform(sFilename.begin(), sFilename.end(), sFilename.begin(), [](char c) noexcept
+                    {
+                        return gsl::narrow_cast<char>(std::tolower(c));
+                    });
+
+                    // cannot reliably detect injection without ObRegisterCallbacks, which requires Vista,
+                    // instead just look for the default process names (it's better than nothing)
+                    // [Cheat Engine.exe, cheatengine-i386.exe, cheatengine-x86_64.exe]
+                    if (sFilename.find("cheat") != std::string::npos &&
+                        sFilename.find("engine") != std::string::npos)
+                    {
+                        RA_LOG_WARN("Cheat Engine detected");
+                        bFound = true;
+                        break;
+                    }
+                }
+            } while (Process32Next(hSnapshot, &pe32));
+        }
+        CloseHandle(hSnapshot);
+    }
+
+    return bFound;
+}
+
+bool Desktop::IsDebuggerPresent() const
+{
+#ifdef NDEBUG // allow debugger-limited functionality when using a DEBUG build
+    if (::IsDebuggerPresent())
+    {
+        RA_LOG_WARN("Debugger detected");
+        return true;
+    }
+
+    BOOL bDebuggerPresent;
+    if (::CheckRemoteDebuggerPresent(GetCurrentProcess(), &bDebuggerPresent) && bDebuggerPresent)
+    {
+        RA_LOG_WARN("Remote debugger detected");
+        return true;
+    }
+#endif
+
+    // real cheating tools don't register as debuggers (or intercept and override the call)
+    // also check for known bad agents and assume that the player is using them to cheat
+    return IsSuspiciousProcessRunning();
+}
+
 
 void Desktop::Shutdown() noexcept
 {
diff --git a/src/ui/win32/Desktop.hh b/src/ui/win32/Desktop.hh
index 357833ee..be823f22 100644
--- a/src/ui/win32/Desktop.hh
+++ b/src/ui/win32/Desktop.hh
@@ -33,6 +33,8 @@ public:
 
     std::unique_ptr<ra::ui::drawing::ISurface> CaptureClientArea(const WindowViewModelBase& vmViewModel) const override;
 
+    bool IsDebuggerPresent() const override;
+
 private:
     _Success_(return != nullptr)
     _NODISCARD IDialogPresenter* GetDialogPresenter(_In_ const WindowViewModelBase& oViewModel) const;


Test patch:
diff --git a/tests/data/EmulatorContext_Tests.cpp b/tests/data/EmulatorContext_Tests.cpp
index 16943361..d782c9e2 100644
--- a/tests/data/EmulatorContext_Tests.cpp
+++ b/tests/data/EmulatorContext_Tests.cpp
@@ -4,6 +4,7 @@
 
 #include "tests\RA_UnitTestHelpers.h"
 
+#include "tests\mocks\MockClock.hh"
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockFileSystem.hh"
@@ -29,6 +30,7 @@ private:
         ra::api::mocks::MockServer mockServer;
         ra::data::mocks::MockGameContext mockGameContext;
         ra::data::mocks::MockUserContext mockUserContext;
+        ra::services::mocks::MockClock mockClock;
         ra::services::mocks::MockConfiguration mockConfiguration;
         ra::services::mocks::MockFileSystem mockFileSystem;
         ra::services::mocks::MockHttpRequester mockHttpRequester;
@@ -1185,6 +1187,50 @@ public:
         emulator.WriteMemory(4U, MemSize::Nibble_Upper, 0x0F);
         Assert::AreEqual((uint8_t)0xFF, memory.at(4));
     }
+
+    TEST_METHOD(TestIsMemoryInsecureCached)
+    {
+        EmulatorContextHarness emulator;
+        Assert::IsFalse(emulator.IsMemoryInsecure());
+
+        emulator.mockDesktop.SetDebuggerPresent(true);
+        Assert::IsFalse(emulator.IsMemoryInsecure());
+
+        emulator.mockClock.AdvanceTime(std::chrono::seconds(5));
+        Assert::IsFalse(emulator.IsMemoryInsecure());
+
+        emulator.mockClock.AdvanceTime(std::chrono::seconds(6));
+        Assert::IsTrue(emulator.IsMemoryInsecure());
+    }
+
+    TEST_METHOD(TestIsMemoryInsecureRememberedUntilReset)
+    {
+        EmulatorContextHarness emulator;
+        emulator.mockDesktop.SetDebuggerPresent(true);
+        Assert::IsTrue(emulator.IsMemoryInsecure());
+
+        emulator.mockDesktop.SetDebuggerPresent(false);
+        Assert::IsTrue(emulator.IsMemoryInsecure());
+
+        emulator.mockClock.AdvanceTime(std::chrono::seconds(100));
+        Assert::IsTrue(emulator.IsMemoryInsecure());
+
+        emulator.ResetMemoryModified();
+        Assert::IsFalse(emulator.IsMemoryInsecure());
+    }
+
+    TEST_METHOD(TestIsMemoryInsecureRecheckedWhenReset)
+    {
+        EmulatorContextHarness emulator;
+        emulator.mockDesktop.SetDebuggerPresent(true);
+        Assert::IsTrue(emulator.IsMemoryInsecure());
+
+        emulator.mockClock.AdvanceTime(std::chrono::seconds(100));
+        Assert::IsTrue(emulator.IsMemoryInsecure());
+
+        emulator.ResetMemoryModified();
+        Assert::IsTrue(emulator.IsMemoryInsecure());
+    }
 };
 
 std::array<uint8_t, 64> EmulatorContext_Tests::memory;
diff --git a/tests/data/GameContext_Tests.cpp b/tests/data/GameContext_Tests.cpp
index 8ca641b4..a8f78539 100644
--- a/tests/data/GameContext_Tests.cpp
+++ b/tests/data/GameContext_Tests.cpp
@@ -12,6 +12,7 @@
 #include "tests\mocks\MockEmulatorContext.hh"
 #include "tests\mocks\MockClock.hh"
 #include "tests\mocks\MockConfiguration.hh"
+#include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockLocalStorage.hh"
 #include "tests\mocks\MockOverlayManager.hh"
 #include "tests\mocks\MockServer.hh"
@@ -1290,6 +1291,65 @@ public:
         game.mockThreadPool.ExecuteNextTask();
     }
 
+    TEST_METHOD(TestAwardAchievementMemoryInsecureHardcore)
+    {
+        GameContextHarness game;
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::AchievementTriggeredNotifications, true);
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
+        game.mockServer.ExpectUncalled<ra::api::AwardAchievement>();
+
+        game.MockAchievement();
+        game.mockEmulator.MockMemoryInsecure(true);
+        game.AwardAchievement(1U);
+
+        Assert::IsTrue(game.mockAudioSystem.WasAudioFilePlayed(L"Overlay\\acherror.wav"));
+        const auto* pPopup = game.mockOverlayManager.GetMessage(1);
+        Expects(pPopup != nullptr);
+        Assert::IsNotNull(pPopup);
+        Assert::AreEqual(std::wstring(L"Achievement NOT Unlocked"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L"AchievementTitle (5)"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"Error: RAM insecure"), pPopup->GetDetail());
+        Assert::IsTrue(pPopup->IsDetailError());
+        Assert::AreEqual(std::string("12345"), pPopup->GetImage().Name());
+
+        // AwardAchievement API call is async, try to execute it - expect no tasks queued
+        game.mockThreadPool.ExecuteNextTask();
+    }
+
+    TEST_METHOD(TestAwardAchievementMemoryInsecureNonHardcore)
+    {
+        GameContextHarness game;
+        game.SetGameHash("hash");
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::AchievementTriggeredNotifications, true);
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
+        game.mockServer.HandleRequest<ra::api::AwardAchievement>([](const ra::api::AwardAchievement::Request& request, ra::api::AwardAchievement::Response& response)
+        {
+            Assert::AreEqual(1U, request.AchievementId);
+            Assert::AreEqual(false, request.Hardcore);
+            Assert::AreEqual(std::string("hash"), request.GameHash);
+
+            response.NewPlayerScore = 125U;
+            response.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        game.MockAchievement();
+        game.mockEmulator.MockMemoryInsecure(true);
+        game.AwardAchievement(1U);
+
+        Assert::IsTrue(game.mockAudioSystem.WasAudioFilePlayed(L"Overlay\\unlock.wav"));
+        const auto* pPopup = game.mockOverlayManager.GetMessage(1);
+        Expects(pPopup != nullptr);
+        Assert::IsNotNull(pPopup);
+        Assert::AreEqual(std::wstring(L"Achievement Unlocked"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L"AchievementTitle (5)"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"AchievementDescription"), pPopup->GetDetail());
+        Assert::AreEqual(std::string("12345"), pPopup->GetImage().Name());
+
+        // AwardAchievement API call is async, try to execute it - expect no tasks queued
+        game.mockThreadPool.ExecuteNextTask();
+    }
+
     TEST_METHOD(TestAwardAchievementDuplicate)
     {
         GameContextHarness game;
@@ -1949,6 +2009,33 @@ public:
         Assert::IsTrue(pPopup->IsDetailError());
     }
 
+    TEST_METHOD(TestSubmitLeaderboardEntryMemoryInsecure)
+    {
+        GameContextHarness game;
+        game.SetGameHash("hash");
+        game.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
+
+        game.mockServer.ExpectUncalled<ra::api::SubmitLeaderboardEntry>();
+
+        game.MockLeaderboard();
+        game.mockEmulator.MockMemoryInsecure(true);
+        game.SubmitLeaderboardEntry(1U, 1234U);
+
+        // SubmitLeaderboardEntry API call is async, try to execute it - expect no tasks queued
+        game.mockThreadPool.ExecuteNextTask();
+
+        Assert::IsTrue(game.mockAudioSystem.WasAudioFilePlayed(L"Overlay\\info.wav"));
+
+        // error message should be reported
+        const auto* pPopup = game.mockOverlayManager.GetMessage(1);
+        Expects(pPopup != nullptr);
+        Assert::IsNotNull(pPopup);
+        Assert::AreEqual(std::wstring(L"Leaderboard NOT Submitted"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L"LeaderboardTitle"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"Error: RAM insecure"), pPopup->GetDetail());
+        Assert::IsTrue(pPopup->IsDetailError());
+    }
+
     TEST_METHOD(TestLoadCodeNotes)
     {
         GameContextNotifyTarget notifyTarget;
diff --git a/tests/mocks/MockDesktop.hh b/tests/mocks/MockDesktop.hh
index f67f5e84..56e0fd3b 100644
--- a/tests/mocks/MockDesktop.hh
+++ b/tests/mocks/MockDesktop.hh
@@ -91,6 +91,9 @@ public:
 
     std::string GetOSVersionString() const override { return "UnitTests"; }
 
+    bool IsDebuggerPresent() const noexcept override { return m_bDebuggerPresent; }
+    void SetDebuggerPresent(bool bValue) noexcept { m_bDebuggerPresent = bValue; }
+
 private:
     ra::ui::DialogResult Handle(WindowViewModelBase& vmViewModel) const
     {
@@ -109,6 +112,7 @@ private:
     mutable bool m_bDialogShown = false;
     mutable std::string m_sLastOpenedUrl;
     std::string m_sExecutable;
+    bool m_bDebuggerPresent = false;
 };
 
 } // namespace mocks
diff --git a/tests/mocks/MockEmulatorContext.hh b/tests/mocks/MockEmulatorContext.hh
index 797e8cfc..9999477f 100644
--- a/tests/mocks/MockEmulatorContext.hh
+++ b/tests/mocks/MockEmulatorContext.hh
@@ -72,6 +72,9 @@ public:
         OnTotalMemorySizeChanged();
     }
 
+    void MockMemoryInsecure(bool bValue) noexcept { m_bMemoryInsecure = bValue; }
+    bool IsMemoryInsecure() const noexcept override { return m_bMemoryInsecure; }
+
 private:
     static uint8_t ReadMemoryHelper(uint32_t nAddress) noexcept
     {