diff --git a/AntiCheat.cpp b/AntiCheat.cpp index 7a76632..b19ea4e 100644 --- a/AntiCheat.cpp +++ b/AntiCheat.cpp @@ -1,19 +1,42 @@ // AntiCheat.cpp -// Javelin Project - Minimal Anti-Cheat guards -// Features: debugger detection, suspicious process scan, basic self-integrity (CRC32) +// Javelin Project - baseline anti-cheat guards. +// +// Features: +// - debugger detection +// - suspicious process scan +// - optional self-integrity check using CRC32 of the running executable -#include -#include -#include +#include +#include +#include +#include +#include #include -#include +#include +#include #include -#include +#include + +#ifdef _WIN32 +#define NOMINMAX +#include +#include +#else +#include +#ifdef __APPLE__ +#include +#include +#endif +#endif + +namespace { -static const char* kTag = "[Javelin AntiCheat] "; +constexpr const char* kTag = "[Javelin AntiCheat] "; +constexpr int kExitDebugger = 0xDB; +constexpr int kExitSuspiciousProcess = 0xBA; +constexpr int kExitIntegrity = 0xC0; -// --- Configurable lists --- -static std::vector kSuspiciousProcesses = { +const std::vector kSuspiciousProcesses = { "cheatengine.exe", "ollydbg.exe", "x64dbg.exe", @@ -21,101 +44,204 @@ static std::vector kSuspiciousProcesses = { "ida.exe", "ida64.exe", "scylla.exe", - "processhacker.exe" + "processhacker.exe", }; -// --- Utils --- -static std::string toLower(std::string s) { - std::transform(s.begin(), s.end(), s.begin(), ::tolower); - return s; +std::string toLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; } -// Simple CRC32 (polynomial 0xEDB88320) -static uint32_t crc32(const std::vector& data) { +std::string basename(std::string path) { + const auto slash = path.find_last_of("/\\"); + if (slash != std::string::npos) { + path = path.substr(slash + 1); + } + return path; +} + +std::string stripExeSuffix(const std::string& name) { + constexpr const char* suffix = ".exe"; + if (name.size() >= 4 && name.compare(name.size() - 4, 4, suffix) == 0) { + return name.substr(0, name.size() - 4); + } + return name; +} + +bool isSuspiciousProcessName(const std::string& processName) { + const std::string normalized = stripExeSuffix(toLower(basename(processName))); + for (const std::string& bad : kSuspiciousProcesses) { + const std::string suspicious = stripExeSuffix(toLower(bad)); + if (normalized == suspicious) { + return true; + } + } + return false; +} + +uint32_t crc32(const std::vector& data) { uint32_t crc = 0xFFFFFFFFu; - for (uint8_t b : data) { - crc ^= b; + for (uint8_t byte : data) { + crc ^= byte; for (int i = 0; i < 8; ++i) { - uint32_t mask = -(crc & 1u); + const uint32_t mask = -(crc & 1u); crc = (crc >> 1) ^ (0xEDB88320u & mask); } } return ~crc; } -static bool readFile(const std::wstring& path, std::vector& out) { - std::ifstream f(path, std::ios::binary); - if (!f) return false; - f.seekg(0, std::ios::end); - std::streamsize size = f.tellg(); - if (size <= 0) return false; - f.seekg(0, std::ios::beg); +bool readFile(const std::string& path, std::vector& out) { + std::ifstream file(path, std::ios::binary); + if (!file) { + return false; + } + + file.seekg(0, std::ios::end); + const std::streamsize size = file.tellg(); + if (size <= 0) { + return false; + } + + file.seekg(0, std::ios::beg); out.resize(static_cast(size)); - if (!f.read(reinterpret_cast(out.data()), size)) return false; - return true; + return static_cast(file.read(reinterpret_cast(out.data()), size)); } -// --- Checks --- -static bool checkDebugger() { - if (IsDebuggerPresent()) return true; - - // Secondary anti-debug: CheckBeingDebugged flag in PEB (best-effort) -#ifdef _M_IX86 - // 32-bit: fs:[30h] -> PEB, offset 2 = BeingDebugged (BYTE) - __try { - BYTE* peb = *(BYTE**)_readfsdword(0x30); - if (peb && peb[2]) return true; - } __except (EXCEPTION_EXECUTE_HANDLER) {} -#elif defined(_M_X64) - // 64-bit: gs:[60h] -> PEB - __try { - BYTE* peb = *(BYTE**)_readgsqword(0x60); - if (peb && peb[2]) return true; - } __except (EXCEPTION_EXECUTE_HANDLER) {} +bool getExecutablePath(std::string& out) { +#ifdef _WIN32 + std::array path{}; + const DWORD written = GetModuleFileNameA(nullptr, path.data(), static_cast(path.size())); + if (written == 0 || written >= path.size()) { + return false; + } + out.assign(path.data(), written); + return true; +#elif defined(__APPLE__) + uint32_t size = 0; + _NSGetExecutablePath(nullptr, &size); + if (size == 0) { + return false; + } + std::string path(size, '\0'); + if (_NSGetExecutablePath(path.data(), &size) != 0) { + return false; + } + path.resize(std::char_traits::length(path.c_str())); + out = path; + return true; +#else + std::array path{}; + const ssize_t written = readlink("/proc/self/exe", path.data(), path.size() - 1); + if (written <= 0) { + return false; + } + out.assign(path.data(), static_cast(written)); + return true; #endif +} +bool checkDebugger() { +#ifdef _WIN32 + if (IsDebuggerPresent()) { + return true; + } + + BOOL remoteDebuggerPresent = FALSE; + if (CheckRemoteDebuggerPresent(GetCurrentProcess(), &remoteDebuggerPresent) && + remoteDebuggerPresent) { + return true; + } + return false; +#elif defined(__linux__) + std::ifstream status("/proc/self/status"); + std::string line; + while (std::getline(status, line)) { + if (line.rfind("TracerPid:", 0) == 0) { + std::istringstream stream(line.substr(10)); + int tracerPid = 0; + stream >> tracerPid; + return tracerPid != 0; + } + } + return false; +#elif defined(__APPLE__) + int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()}; + kinfo_proc info{}; + size_t size = sizeof(info); + if (sysctl(mib, 4, &info, &size, nullptr, 0) != 0) { + return false; + } + return (info.kp_proc.p_flag & P_TRACED) != 0; +#else return false; +#endif } -static bool checkSuspiciousProcesses() { +bool checkSuspiciousProcesses() { +#ifdef _WIN32 HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snap == INVALID_HANDLE_VALUE) return false; + if (snap == INVALID_HANDLE_VALUE) { + return false; + } - PROCESSENTRY32 pe{}; - pe.dwSize = sizeof(pe); - if (!Process32First(snap, &pe)) { + PROCESSENTRY32A entry{}; + entry.dwSize = sizeof(entry); + if (!Process32FirstA(snap, &entry)) { CloseHandle(snap); return false; } do { - std::string name = toLower(pe.szExeFile); - for (const auto& bad : kSuspiciousProcesses) { - if (name == toLower(bad)) { - CloseHandle(snap); - return true; - } + if (isSuspiciousProcessName(entry.szExeFile)) { + CloseHandle(snap); + return true; } - } while (Process32Next(snap, &pe)); + } while (Process32NextA(snap, &entry)); CloseHandle(snap); return false; +#else + FILE* pipe = popen("ps -axo comm", "r"); + if (pipe == nullptr) { + return false; + } + + std::array buffer{}; + while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { + std::string processName(buffer.data()); + processName.erase(std::remove(processName.begin(), processName.end(), '\n'), processName.end()); + if (isSuspiciousProcessName(processName)) { + pclose(pipe); + return true; + } + } + + pclose(pipe); + return false; +#endif } -static bool checkSelfIntegrity(uint32_t expectedCrc) { - wchar_t path[MAX_PATH]{}; - if (!GetModuleFileNameW(nullptr, path, MAX_PATH)) return false; +bool checkSelfIntegrity(uint32_t expectedCrc) { + std::string path; + if (!getExecutablePath(path)) { + return false; + } std::vector bytes; - if (!readFile(path, bytes)) return false; + if (!readFile(path, bytes)) { + return false; + } - uint32_t current = crc32(bytes); - return current == expectedCrc; + return crc32(bytes) == expectedCrc; } -// --- Entry helper (embed a baseline CRC once you ship a build) --- +} // namespace + #ifndef JAVELIN_EXPECTED_CRC32 -#define JAVELIN_EXPECTED_CRC32 0u // Set this at build time (e.g., /DJAVELIN_EXPECTED_CRC32=0x12345678) +#define JAVELIN_EXPECTED_CRC32 0u #endif int main() { @@ -123,19 +249,17 @@ int main() { if (checkDebugger()) { std::cerr << kTag << "Debugger detected. Exiting.\n"; - return 0xDEB; // code for debugger + return kExitDebugger; } if (checkSuspiciousProcesses()) { std::cerr << kTag << "Suspicious process detected. Exiting.\n"; - return 0xBAD; // code for bad process + return kExitSuspiciousProcess; } - if (JAVELIN_EXPECTED_CRC32 != 0u) { - if (!checkSelfIntegrity(JAVELIN_EXPECTED_CRC32)) { - std::cerr << kTag << "Integrity check failed (CRC mismatch). Exiting.\n"; - return 0xCRC; // custom code (note: non-standard, may be truncated) - } + if (JAVELIN_EXPECTED_CRC32 != 0u && !checkSelfIntegrity(JAVELIN_EXPECTED_CRC32)) { + std::cerr << kTag << "Integrity check failed (CRC mismatch). Exiting.\n"; + return kExitIntegrity; } std::cout << kTag << "All clear. Continue.\n"; diff --git a/README.md b/README.md index eb4d6af..3b88d22 100644 --- a/README.md +++ b/README.md @@ -1 +1,82 @@ -# py-workedtask \ No newline at end of file +# py-workedtask + +Baseline anti-cheat guards for the Javelin Project. + +## What Is Covered + +- C++ client debugger detection. +- C++ client suspicious process detection. +- Optional C++ self-integrity verification using a build-time CRC32 value. +- Python monitor debugger detection. +- Python monitor suspicious process detection. +- Optional Python self-integrity verification using `JAVELIN_EXPECTED_SHA256`. + +The guards fail closed: when a configured check fails, the process exits before the protected application would continue. + +## Run The Python Monitor + +```bash +python3 anti_cheat.py +``` + +To enable Python script integrity verification, compute the SHA-256 of the monitor and pass it through the environment: + +```bash +export JAVELIN_EXPECTED_SHA256="$(python3 - <<'PY' +import hashlib +from pathlib import Path +print(hashlib.sha256(Path("anti_cheat.py").read_bytes()).hexdigest()) +PY +)" +python3 anti_cheat.py +``` + +If the script changes after the hash is generated, the monitor exits with the integrity failure code. + +## Build The C++ Client + +Linux/macOS: + +```bash +c++ -std=c++17 -Wall -Wextra -pedantic AntiCheat.cpp -o javelin-anticheat +./javelin-anticheat +``` + +Windows Developer Command Prompt: + +```bat +cl /std:c++17 /EHsc AntiCheat.cpp /Fe:javelin-anticheat.exe +javelin-anticheat.exe +``` + +To enable C++ executable integrity verification, build once, calculate the CRC32 of the release executable, and rebuild with the expected value: + +```bash +python3 - <<'PY' +import binascii +from pathlib import Path +print(hex(binascii.crc32(Path("javelin-anticheat").read_bytes()) & 0xffffffff)) +PY +c++ -std=c++17 -DJAVELIN_EXPECTED_CRC32=0x12345678 AntiCheat.cpp -o javelin-anticheat +``` + +Replace `0x12345678` with the CRC32 produced for your release artifact. A mismatch exits before the protected application continues. + +## Exit Codes + +| Code | Meaning | +| --- | --- | +| `0` | All checks passed | +| `0xDB` | Debugger detected | +| `0xBA` | Suspicious process detected | +| `0xC0` | Integrity verification failed | + +## Tests + +```bash +python3 -m unittest discover -s tests +python3 -m py_compile anti_cheat.py +c++ -std=c++17 -Wall -Wextra -pedantic AntiCheat.cpp -o /tmp/javelin-anticheat-check +/tmp/javelin-anticheat-check +git diff --check +``` diff --git a/anti_cheat.py b/anti_cheat.py new file mode 100644 index 0000000..6afbd84 --- /dev/null +++ b/anti_cheat.py @@ -0,0 +1,173 @@ +"""Baseline anti-cheat monitor for the Javelin Project. + +The monitor intentionally avoids third-party dependencies so it can run beside +the C++ client in minimal environments. +""" + +from __future__ import annotations + +import csv +import ctypes +import hashlib +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable, Sequence + + +TAG = "[Javelin AntiCheat]" +EXIT_DEBUGGER = 0xDB +EXIT_SUSPICIOUS_PROCESS = 0xBA +EXIT_INTEGRITY = 0xC0 + +SUSPICIOUS_PROCESSES: tuple[str, ...] = ( + "cheatengine.exe", + "ollydbg.exe", + "x64dbg.exe", + "httpdebuggerui.exe", + "ida.exe", + "ida64.exe", + "scylla.exe", + "processhacker.exe", +) + + +@dataclass(frozen=True) +class CheckResult: + ok: bool + exit_code: int + message: str + + +ProcessRunner = Callable[..., subprocess.CompletedProcess[str]] + + +def _normalize_process_name(name: str) -> str: + cleaned = name.strip().strip('"').replace("\\", "/") + base = Path(cleaned).name.lower() + if base.endswith(".exe"): + base = base[:-4] + return base + + +def is_suspicious_process_name(name: str, suspicious: Sequence[str] = SUSPICIOUS_PROCESSES) -> bool: + normalized = _normalize_process_name(name) + return normalized in {_normalize_process_name(candidate) for candidate in suspicious} + + +def _is_debugger_present_windows() -> bool: + try: + return bool(ctypes.windll.kernel32.IsDebuggerPresent()) + except (AttributeError, OSError): + return False + + +def _tracer_pid_from_proc_status(status_path: Path = Path("/proc/self/status")) -> int: + try: + for line in status_path.read_text(encoding="utf-8").splitlines(): + if line.startswith("TracerPid:"): + return int(line.split(":", 1)[1].strip()) + except (OSError, ValueError): + return 0 + return 0 + + +def is_debugger_attached() -> bool: + if sys.gettrace() is not None: + return True + if os.name == "nt": + return _is_debugger_present_windows() + return _tracer_pid_from_proc_status() != 0 + + +def _process_command() -> list[str]: + if os.name == "nt": + return ["tasklist", "/FO", "CSV", "/NH"] + return ["ps", "-axo", "comm="] + + +def _parse_process_names(output: str) -> list[str]: + if os.name == "nt": + names: list[str] = [] + for row in csv.reader(output.splitlines()): + if row: + names.append(row[0]) + return names + return [line.strip() for line in output.splitlines() if line.strip()] + + +def list_process_names(runner: ProcessRunner = subprocess.run) -> list[str]: + try: + completed = runner( + _process_command(), + check=False, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + if completed.returncode != 0: + return [] + return _parse_process_names(completed.stdout) + + +def has_suspicious_process( + process_names: Iterable[str] | None = None, + suspicious: Sequence[str] = SUSPICIOUS_PROCESSES, +) -> bool: + names = process_names if process_names is not None else list_process_names() + return any(is_suspicious_process_name(name, suspicious) for name in names) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def expected_sha256_from_env() -> str | None: + value = os.environ.get("JAVELIN_EXPECTED_SHA256") + if value is None: + return None + value = value.strip().lower() + return value or None + + +def check_self_integrity(expected_sha256: str | None = None, script_path: Path | None = None) -> bool: + expected = expected_sha256 if expected_sha256 is not None else expected_sha256_from_env() + if expected is None: + return True + if len(expected) != 64 or any(char not in "0123456789abcdef" for char in expected): + return False + target = script_path if script_path is not None else Path(__file__) + try: + return sha256_file(target) == expected + except OSError: + return False + + +def run_checks() -> CheckResult: + if is_debugger_attached(): + return CheckResult(False, EXIT_DEBUGGER, "Debugger detected. Exiting.") + if has_suspicious_process(): + return CheckResult(False, EXIT_SUSPICIOUS_PROCESS, "Suspicious process detected. Exiting.") + if not check_self_integrity(): + return CheckResult(False, EXIT_INTEGRITY, "Integrity check failed (SHA-256 mismatch). Exiting.") + return CheckResult(True, 0, "All clear. Continue.") + + +def main() -> int: + print(f"{TAG} starting checks...") + result = run_checks() + stream = sys.stdout if result.ok else sys.stderr + print(f"{TAG} {result.message}", file=stream) + return result.exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/DEMO.md b/docs/DEMO.md new file mode 100644 index 0000000..33a2c58 --- /dev/null +++ b/docs/DEMO.md @@ -0,0 +1,12 @@ +# Javelin Anti-Cheat Demo + +This folder contains a short visual demo for PR review: + +![Javelin anti-cheat demo](./javelin-anticheat-demo.gif) + +The demo shows: + +- Python regression tests passing. +- C++ client compiling and passing the all-clear path. +- Python SHA-256 integrity mismatch exiting with the guarded failure code. +- C++ CRC integrity mismatch exiting with the guarded failure code. diff --git a/docs/javelin-anticheat-demo.gif b/docs/javelin-anticheat-demo.gif new file mode 100644 index 0000000..9487ba9 Binary files /dev/null and b/docs/javelin-anticheat-demo.gif differ diff --git a/tests/test_anti_cheat.py b/tests/test_anti_cheat.py new file mode 100644 index 0000000..d3fe5d1 --- /dev/null +++ b/tests/test_anti_cheat.py @@ -0,0 +1,87 @@ +import hashlib +import subprocess +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +import anti_cheat + + +class AntiCheatMonitorTests(unittest.TestCase): + def test_suspicious_process_matching_is_case_insensitive_and_path_safe(self): + self.assertTrue(anti_cheat.is_suspicious_process_name(r"C:\Tools\X64DBG.EXE")) + self.assertTrue(anti_cheat.is_suspicious_process_name("/opt/tools/cheatengine")) + self.assertFalse(anti_cheat.is_suspicious_process_name("/usr/bin/python3")) + + def test_parse_process_names_from_posix_output(self): + with mock.patch.object(anti_cheat.os, "name", "posix"): + self.assertEqual( + anti_cheat._parse_process_names("python3\nx64dbg\n\n"), + ["python3", "x64dbg"], + ) + + def test_list_process_names_uses_runner_output(self): + def fake_runner(*args, **kwargs): + return subprocess.CompletedProcess(args=args, returncode=0, stdout="python3\nida64\n", stderr="") + + with mock.patch.object(anti_cheat.os, "name", "posix"): + self.assertEqual(anti_cheat.list_process_names(runner=fake_runner), ["python3", "ida64"]) + + def test_has_suspicious_process_detects_known_tool(self): + self.assertTrue(anti_cheat.has_suspicious_process(["python3", "processhacker.exe"])) + self.assertFalse(anti_cheat.has_suspicious_process(["python3", "node"])) + + def test_self_integrity_accepts_matching_sha256(self): + with TemporaryDirectory() as tmpdir: + script = Path(tmpdir) / "anti_cheat.py" + script.write_text("print('ok')\n", encoding="utf-8") + expected = hashlib.sha256(script.read_bytes()).hexdigest() + + self.assertTrue(anti_cheat.check_self_integrity(expected, script)) + + def test_self_integrity_rejects_mismatch_and_malformed_hash(self): + with TemporaryDirectory() as tmpdir: + script = Path(tmpdir) / "anti_cheat.py" + script.write_text("print('tampered')\n", encoding="utf-8") + + self.assertFalse(anti_cheat.check_self_integrity("0" * 64, script)) + self.assertFalse(anti_cheat.check_self_integrity("not-a-sha", script)) + + def test_run_checks_stops_on_debugger(self): + with mock.patch.object(anti_cheat, "is_debugger_attached", return_value=True): + result = anti_cheat.run_checks() + + self.assertFalse(result.ok) + self.assertEqual(result.exit_code, anti_cheat.EXIT_DEBUGGER) + + def test_run_checks_stops_on_suspicious_process(self): + with mock.patch.object(anti_cheat, "is_debugger_attached", return_value=False), mock.patch.object( + anti_cheat, "has_suspicious_process", return_value=True + ): + result = anti_cheat.run_checks() + + self.assertFalse(result.ok) + self.assertEqual(result.exit_code, anti_cheat.EXIT_SUSPICIOUS_PROCESS) + + def test_run_checks_stops_on_integrity_failure(self): + with mock.patch.object(anti_cheat, "is_debugger_attached", return_value=False), mock.patch.object( + anti_cheat, "has_suspicious_process", return_value=False + ), mock.patch.object(anti_cheat, "check_self_integrity", return_value=False): + result = anti_cheat.run_checks() + + self.assertFalse(result.ok) + self.assertEqual(result.exit_code, anti_cheat.EXIT_INTEGRITY) + + def test_run_checks_all_clear(self): + with mock.patch.object(anti_cheat, "is_debugger_attached", return_value=False), mock.patch.object( + anti_cheat, "has_suspicious_process", return_value=False + ), mock.patch.object(anti_cheat, "check_self_integrity", return_value=True): + result = anti_cheat.run_checks() + + self.assertTrue(result.ok) + self.assertEqual(result.exit_code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpp_build.py b/tests/test_cpp_build.py new file mode 100644 index 0000000..a5c9975 --- /dev/null +++ b/tests/test_cpp_build.py @@ -0,0 +1,59 @@ +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +class CppAntiCheatBuildTests(unittest.TestCase): + def test_cpp_client_compiles_and_runs_without_configured_crc(self): + compiler = shutil.which("c++") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("No C++ compiler available") + + repo_root = Path(__file__).resolve().parents[1] + source = repo_root / "AntiCheat.cpp" + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "javelin-anticheat-check" + subprocess.run( + [compiler, "-std=c++17", "-Wall", "-Wextra", "-pedantic", str(source), "-o", str(output)], + check=True, + cwd=repo_root, + ) + completed = subprocess.run([str(output)], check=False, capture_output=True, text=True) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("All clear", completed.stdout) + + def test_cpp_client_exits_on_configured_crc_mismatch(self): + compiler = shutil.which("c++") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("No C++ compiler available") + + repo_root = Path(__file__).resolve().parents[1] + source = repo_root / "AntiCheat.cpp" + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "javelin-anticheat-check" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-pedantic", + "-DJAVELIN_EXPECTED_CRC32=0x1", + str(source), + "-o", + str(output), + ], + check=True, + cwd=repo_root, + ) + completed = subprocess.run([str(output)], check=False, capture_output=True, text=True) + + self.assertEqual(completed.returncode, 0xC0, completed.stderr) + self.assertIn("Integrity check failed", completed.stderr) + + +if __name__ == "__main__": + unittest.main()