├── img ├── nullptr.png ├── callstack.png ├── esp_adjust.png ├── ceg_calcmemorycrc.png ├── ceg_exitprocess.png └── CEG_SV_ProcessPendingSaves.png ├── CRC.md ├── README.md ├── code └── anti-anti-tamper.cpp └── ANALYSIS.md /img/nullptr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rattpak/CEG-Anti-Tamper-Analysis/HEAD/img/nullptr.png -------------------------------------------------------------------------------- /img/callstack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rattpak/CEG-Anti-Tamper-Analysis/HEAD/img/callstack.png -------------------------------------------------------------------------------- /img/esp_adjust.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rattpak/CEG-Anti-Tamper-Analysis/HEAD/img/esp_adjust.png -------------------------------------------------------------------------------- /img/ceg_calcmemorycrc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rattpak/CEG-Anti-Tamper-Analysis/HEAD/img/ceg_calcmemorycrc.png -------------------------------------------------------------------------------- /img/ceg_exitprocess.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rattpak/CEG-Anti-Tamper-Analysis/HEAD/img/ceg_exitprocess.png -------------------------------------------------------------------------------- /img/CEG_SV_ProcessPendingSaves.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rattpak/CEG-Anti-Tamper-Analysis/HEAD/img/CEG_SV_ProcessPendingSaves.png -------------------------------------------------------------------------------- /CRC.md: -------------------------------------------------------------------------------- 1 | # CEG_CalcMemoryCRC 2 | For those curious, here are the registers used in `CEG_CalcMemoryCRC` (Latest Steam `t6sp.exe` binary, address `0x637F30`): 3 | 4 | `EDI`: Total bytes to check 5 | 6 | `ECX`: Base address of the region 7 | 8 | `EDX`: Offset (starts at 0) 9 | 10 | `EBP`: CRC lookup table 11 | 12 | `EBX`: Byte currently being checked 13 | 14 | Here is the function itself in IDA, with extra comments I left during my time researching 15 | 16 | ![alt text](https://github.com/Rattpak/CEG-Anti-Tamper-Analysis/blob/7a2b4dbfbaa0d4545ab5f5d32740e4cde247662d/img/ceg_calcmemorycrc.png "CEG_CalcMemoryCRC function in IDA with labeled registers") 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CEG Anti-Tamper Analysis 2 | 3 | Reverse engineering, analysis, and partial disabling of Steam's CEG (Custom Executable Generation) anti-tamper protections in `t6sp.exe` (Call of Duty: Black Ops II singleplayer), while **preserving all anti-piracy mechanisms**. 4 | 5 | > ⚠️ **Disclaimer:** This analysis is for educational and research purposes only. It does **not** and will **never** facilitate piracy or unauthorized distribution of software. The focus is strictly on bypassing tamper protections to allow for legitimate reverse engineering, modding, and debugging workflows. 6 | 7 | 8 | ## Overview 9 | 10 | This repository documents the process of identifying, analyzing, and partially neutralizing CEG’s anti-tamper functionality in `t6sp.exe`. The intent is to allow deeper understanding and modification of the binary for personal or research use, without impacting the core anti-piracy measures enforced by Steam. 11 | 12 | ## Analysis 13 | 14 | For the full technical write-up, including debugger techniques, CRC bypass strategies, and notes on function structure and behavior: 15 | 16 | [**Read the full analysis here**](ANALYSIS.md) 17 | 18 | 19 | ## Notes 20 | 21 | - Focuses exclusively on `t6sp.exe` (Black Ops II SP). 22 | - Preserves all anti-piracy checks. 23 | - Does not contain or link to any copyrighted game files. 24 | - Designed for experienced reverse engineers and hobbyists interested in software protection mechanisms. 25 | -------------------------------------------------------------------------------- /code/anti-anti-tamper.cpp: -------------------------------------------------------------------------------- 1 | //////////////////////////////////// 2 | // In the end, this is all you need to disable 3 | // do disable most anti-tamper checks 4 | // without touching anti-piracy checks 5 | //////////////////////////////////// 6 | 7 | //CEG_CalcMemoryCRC at 0x52C8FC 8 | DWORD crc_jmpBackAddr; 9 | void __declspec(naked) crcHook() { 10 | __asm { 11 | mov esi, ecx 12 | mov eax, 0xFFB97B1F 13 | mov[esi], eax 14 | jmp[crc_jmpBackAddr] 15 | } 16 | } 17 | 18 | void __declspec(naked) svHook() { 19 | __asm { 20 | mov eax, [esp] 21 | push eax 22 | 23 | mov eax, 0x4684E0 24 | call eax 25 | 26 | pop eax 27 | jmp eax 28 | } 29 | } 30 | 31 | //dont disable CEG entirely; Keep anti-piracy but remove anti-tamper 32 | void CEG::setupCRCHooks() { 33 | HANDLE pHandle = GetCurrentProcess(); 34 | 35 | /**Setting up CRC Hooks**/ 36 | //this one protects the CEG_CalcMemoryCRC function itself 37 | //meaning once this is hook, we can do anything to fake CRC 38 | 39 | // No longer needed since the server thread hook is installed 40 | DWORD crcHookAddr = 0x52C8FC; 41 | crc_jmpBackAddr = crcHookAddr + 5; 42 | Hook::initHook((void*)crcHookAddr, crcHook, 5); 43 | 44 | /**Setting up stack pointer adjustment**/ 45 | constexpr std::array ESP_PATCH = { 0x83, 0xC4, 0x0C }; 46 | WriteProcessMemory(pHandle, (LPVOID)0x55A87C, ESP_PATCH.data(), ESP_PATCH.size(), nullptr); 47 | 48 | CEG::print("Setting up server thread hook"); 49 | DWORD svHookAddr = 0x4CC4C0; 50 | Hook::initHook((void*)svHookAddr, svHook, 5); 51 | 52 | /**Setting up ESI instruction**/ 53 | //This is the ESI patch discussed in the analysis documentation 54 | //add esi, 4 ------> mov esi, 1C0 + nop 55 | // Not used but here for reference 56 | constexpr std::array ESI_PATCH = { 0xBE, 0xC0, 0x01, 0x00, 0x00 , 0x90 }; 57 | WriteProcessMemory(pHandle, (LPVOID)0x52C8E5, ESI_PATCH.data(), ESI_PATCH.size(), nullptr); 58 | 59 | /**Disabling SHA1 function checks**/ 60 | //disable SHA1 checks 61 | Hook::nopMem((void*)0x87DFFA, 5); 62 | Hook::nopMem((void*)0x514D31, 5); 63 | 64 | /**Disabling .rdata table checks**/ 65 | //disable the CRC .rdata table ones 66 | Hook::nopMem((void*)0x87E046, 5); 67 | Hook::nopMem((void*)0x5544AC, 5); 68 | } 69 | -------------------------------------------------------------------------------- /ANALYSIS.md: -------------------------------------------------------------------------------- 1 | # Steam CEG Anti-Tamper Bypass (Maintaining Anti-Piracy) 2 | This write-up covers my research and implementation for bypassing specific anti-tamper functionality within Steam's CEG (Custom Executable Generation) system specifically for `t6sp.exe`, the singleplayer executable for *Call of Duty: Black Ops II*. The goal was to remove limitations that interfered with reverse engineering, debugging, and hooking, without enabling unauthorized use or affecting the game's built-in anti-piracy protections. 3 | 4 | During the early phases of research, the code was fairly chaotic. Hooks were scattered everywhere, and it felt like I was playing whack-a-mole with kill switches and integrity traps. But as I learned more about the flow and structure of CEG, everything started to converge and streamline. In the end, thanks to some architectural weaknesses in CEG (e.g., reliance on usermode stubs, predictable call structures, and some important functions not CRC checked), the final code ended up being much smaller and less complex than I initially expected. 5 | 6 | ## Why I Started 7 | I began this research while attempting to hook a function that immediately caused the game to terminate. My initial assumption was that I had implemented the hook incorrectly, so I tried different offsets in the same function, all with the same result. 8 | 9 | To investigate further, I used x32dbg and set a byte hardware breakpoint on access at the address I was trying to hook. The breakpoint was instantly trigger and revealed an instruction at `0x637F4B` that was reading the byte: 10 | ```asm 11 | mov eax, dword ptr ds:[esi] 12 | ``` 13 | Judging by the structure of the function that the instruction was in, it was most likely a CRC. I cross-referenced the function and confirmed it was a part of CEG, as all its references pointed to other known CEG functions. Knowing this, I labeled it `CEG_CalcMemoryCRC` 14 | 15 | If you want to see the internals of this function, see [CRC.md](https://github.com/Rattpak/CEG-Anti-Tamper-Analysis/blob/3d97b926d02e8a8a5fd95533349b8f377d63e97a/CRC.md) 16 | 17 | ## Kill Switches 18 | After setting a breakpoint on that CRC function and letting it run, I watched what used the result. I ended up just NOPing the call entirely and immediately got hit with a last chance exception for an access violation. The instruction that causes the violation is at address `0x8CF79B` 19 | ```asm 20 | mov [eax], ecx 21 | ``` 22 | But just before that? A good old-fashioned: 23 | ```asm 24 | xor eax, eax 25 | ``` 26 | A simple null pointer write. This was a CEG kill switch kicking in, a very simple one too. I labeled this one: `CEG_Killswitch_NullPtr` 27 | 28 | ![alt text](https://github.com/Rattpak/CEG-Anti-Tamper-Analysis/blob/238f13f634e75c763b70a05c4b4aca7bd1594bce/img/nullptr.png "CEG_Killswitch_NullPtr in IDA") 29 | 30 | Using IDA, I could now see several XREFs to both of these functions. Note that not all CEG functions are able to be viewed in static analysis, but these ones are. 31 | 32 | Since this function was still CEG protected, I hooked memcpy instead, and ran a valid memory check like this: 33 | 34 | ```C++ 35 | bool isMemoryReadable(const void* address, size_t size) { 36 | MEMORY_BASIC_INFORMATION mbi; 37 | if (VirtualQuery(address, &mbi, sizeof(mbi)) == 0) { 38 | return false; 39 | } 40 | 41 | DWORD protect = mbi.Protect; 42 | bool readable = (protect & PAGE_READONLY) || (protect & PAGE_READWRITE) || (protect & PAGE_EXECUTE_READ) || (protect & PAGE_EXECUTE_READWRITE); 43 | return readable && ((uintptr_t)address + size <= (uintptr_t)mbi.BaseAddress + mbi.RegionSize); 44 | } 45 | 46 | void* _memcpy_hookfunc(void* a1, const void* Src, size_t Size) { 47 | if (!isMemoryReadable(Src, Size)) { 48 | /**memcpy Kill Attempt Prevented**/ 49 | return a1; 50 | } 51 | 52 | return memcpy(a1, Src, Size); 53 | } 54 | ``` 55 | 56 | Which did work, but this is not the only way CEG will try and end the process, however the rest of them are just as easy to find since CEG uses the usermode stubs for every syscall, meaning placing breakpoints on process-exiting syscalls is trivial. You don’t need to go kernel-deep to catch these, just put a breakpoint on ExitProcess or TerminateProcess, and you’ll hit if you trip CEG. 57 | Setting breakpoints on 58 | ``` 59 | 60 | 61 | ``` 62 | was enough to stop the game from closing when tampering with CEG. However setting breakpoints here is only useful to realize you tripped CEG without actually having the process close. The problem is that CEG smashes the callstack and replaces it with `0x8000DEAD` 63 | 64 | Here is what the callstack will look like when setting a breakpoint on exit syscalls: 65 | 66 | ![alt text](https://github.com/Rattpak/CEG-Anti-Tamper-Analysis/blob/0bcb9d1403ae6635b5a0b2a3266432250c8fb679/img/callstack.png "Example of the callstack being destroyed") 67 | 68 | Heres some code of it: 69 | 70 | ![alt text](https://github.com/Rattpak/CEG-Anti-Tamper-Analysis/blob/fbb35e94a7e9792997fea5e5ac2353739d3f221e/img/ceg_exitprocess.png "The CEG_ExitProcess function in IDA") 71 | 72 | This is not the only location that the `0x8000DEAD` occurs, so its not at simple as removing that part of the function. However, I found the function responsible for this stack smashing and wrote a hook that logs the thread ID and suspends the thread before it can even get to ExitProcess. It worked, and was kind of fun to implement and was very usefull. I eventually scrapped it because again, I wasn’t trying to make a full CEG disabler, I just wanted my hooks to stop triggering kill switches. So at this point my research into CEG killswitches was done. 73 | 74 | ## Attempting to Spoof the CRC 75 | My first idea for the CRC function was to basically gut it and replace its internals with a stub function that takes in the requested CRC start address, and from there, just returns whatever the expected CRC would be. While I still believe this is a viable approach, it didn’t seem practical at the time, especially since I was still deep in the research phase, trying to understand how everything actually worked. 76 | 77 | There are also other complications. For example, `CEG_CalcMemoryCRC` can take the same parameters but yield different CRC results depending on the value in ESI before the function is called. Now I did experiment with this a bit and found a way to force ESI to always be the same. By changing the 78 | ```asm 79 | add esi, 4 80 | ``` 81 | in the loops where `CEG_CalcMemoryCRC` is called to 82 | ```asm 83 | mov esi, 0x1C0 84 | ``` 85 | it will still pass all the CRC checks that are needed to keep CEG happy and the game running, while only doing about 1/5 the amount of checks. In the end i did not end up using this trick, but it was still interesting to see. 86 | 87 | ## Hooking Time 88 | So when I went to modify the internals of the `CEG_CalcMemoryCRC` function, it immediately tripped CEG. A CRC that checks itself. Very nice. Setting a hardware breakpoint inside the function revealed that there was one specific call that would check it, but interestingly, that call itself wasn’t protected by a CRC. 89 | 90 | Since this was the first CRC I started working on for CEG, I set a breakpoint at the end of the CRC calculation, grabbed the value in `EAX`, and made a hook that jumps to a custom function. That function emulates the CRC setup and then just force-returns the correct result. 91 | ```C++ 92 | void __declspec(naked) crcHook() { 93 | __asm { 94 | mov esi, ecx 95 | mov eax, 0xFFB97B1F ;<-------- CRC value 96 | mov[esi], eax 97 | jmp[crc_jmpBackAddr] 98 | } 99 | } 100 | ``` 101 | 102 | Replacing the CRC call here with my hook worked perfectly for what I needed. It gave me full freedom to modify the internals of the CRC function as much as I wanted, which was extremely useful, both for analysis and for seeing exactly what chunks of memory were being checked. 103 | 104 | One important thing to note: CEG doesn't just check the specific function it's interested in. Instead, it checks thousands of bytes before and after. The original function I was trying to hook wasn’t even a CEG function, but it still got caught in the crossfire of a broader CEG memory check. 105 | 106 | ## More CRC Checks 107 | 108 | However, this wasn’t the only function that checked the CRC function, there were others. Fortunately, the rest weren’t called every frame. Instead, they only ran under specific conditions (like level changes and client disconnects). One of those additional checks is called from a function that scans hundreds of other functions, so a simple one-value spoof wasn’t going to cut it. 109 | 110 | **Note:** This list just lists the name of the function where the start addresses of the CRC checks, and does not contain the functions that are inside the CRC checks. 111 | ``` 112 | CEG CRC Integrity Check Locations 113 | 114 | //Functions 115 | Actor_Pain 116 | AimAssist_RegisterDvars 117 | CG_CompassCalcDimensions 118 | CG_SndEntHandle 119 | CG_UpdateClouds 120 | CycleWeapPrimary 121 | DB_PrintXAssetsForType_FastFile 122 | Dvar_Init 123 | Dvar_IsValidName 124 | Expression_MapIndexToFunction 125 | hks::Visitor::visit_children (the one at 0x6009A0) 126 | IPak_AddPackfile 127 | LiveLeaderboard_GetByPlayer 128 | LiveSteam_PopOverlayForSteamID 129 | Live_UpdatePlayerNetAddr 130 | Menu_Paint 131 | mp_reduce_2k_l 132 | OrientationInvert 133 | Party_Init 134 | PlayerCmd_meleeButtonPressed 135 | ReadPathNodes 136 | Scr_Vehicle_Think 137 | SEH_LocalizeTextMessage 138 | SP_info_vehicle_node 139 | VEH_UpdateNitrousPosition 140 | VEH_UpdatePathOffset 141 | SpotLightViewMatrix 142 | standard_query::query 143 | Turret_PlaceTurret_UpdateFooting 144 | (many many more) 145 | ``` 146 | 147 | For now, I’ll refer to this function as the main CRC checking function and for good reason. The parent function (which contains both the CRC I hooked and this "main CRC check") is called from two separate places: `SV_PreFrame_Save` and `SV_ServerThread`. Internally, it intercepts the call to `SV_ProcessPendingSaves`. I named this CRC checker `CEG_SV_RunMemoryCRC`. 148 | 149 | This function runs every server frame. You could nop out the call in both `SV_ServerThread` and `SV_PreFrame_Save`, and that would work, but it would also break the entire savegame system. 150 | 151 | Also worth noting: `SV_ProcessPendingSaves` has no XREFs. That’s because CEG doesn’t call it directly. Instead, it gets the function address manually, stores it in EAX, jumps to it, and pushes the original return address (from either `SV_ServerThread` or `SV_PreFrame_Save`) into the stack pointer. 152 | The solution was actually pretty straightforward: just replace the `CEG_SV_RunMemoryCRC` call with a direct call to `SV_ProcessPendingSaves`. However, that didn’t work immediately, because EAX no longer held the correct value, meaning the jump landed somewhere random in memory. 153 | 154 | ![alt text](https://github.com/Rattpak/CEG-Anti-Tamper-Analysis/blob/59257d56c04dc43cff72c2cfb63df4d7d5e89dc2/img/CEG_SV_ProcessPendingSaves.png) 155 | 156 | Anyway, the good news is, we don’t actually need that indirect jump via EAX anymore, since we already control the hook. So we can just jump out of the hook directly to where we want. 157 | 158 | Here’s the simple assembly I threw together to make that work: 159 | 160 | ```C++ 161 | void __declspec(naked) svHook() { 162 | __asm { 163 | mov eax, [esp] ;<------ the address of the location to jump back to 164 | push eax 165 | 166 | mov eax, 0x4684E0 ;<------ address of actual SV_ProcessPendingSaves 167 | call eax 168 | 169 | pop eax 170 | jmp eax 171 | } 172 | } 173 | ``` 174 | 175 | This code worked perfectly when the function was called from `SV_ServerThread`, but caused issues when it was called from `SV_PreFrame_Save`. Setting a breakpoint and letting it run revealed a glaring issue: the stack pointer needed to be adjusted differently now that we were no longer going through the original CEG functions. 176 | 177 | In the debugger, I noticed that `ESP + 8` now contained `00000001`, which was clearly wrong. The value we actually wanted was now sitting at `ESP + 0xC`. 178 | 179 | ![alt_text](https://github.com/Rattpak/CEG-Anti-Tamper-Analysis/blob/ba79aac125a58a9069e4f1e250cf5d920fc5417d/img/esp_adjust.png "") 180 | 181 | So, changing: 182 | 183 | ```asm 184 | add esp, 8 185 | ``` 186 | to: 187 | ```asm 188 | add esp, 0xC 189 | ``` 190 | inside `SV_PreFrame` completely resolved the issue. 191 | 192 | Interestingly, this new bypass made my original CRC hook obsolete. Still, that hook was extremely useful for understanding how everything fit together. 193 | 194 | ## What's Left? 195 | With this "main CRC" function knocked out, you can now freely edit a ton of other stuff that you couldn’t touch before. If you set a breakpoint inside the CRC function, you’ll still see checks happening, but they’re a lot less frequent and cover far less variety. Since the functions that call it aren’t likely being checked anymore, you can knock out a few easy ones without any real concern. 196 | 197 | There are also CRC checks targeting .rdata (possibly for the CRC result table?) I haven’t looked into that part too deeply. I labeled that one `CEG_CheckRdata`, and you can nop it out without any problems. Same thing goes for another CRC function that protects a CEG SHA1 routine, easy to remove. 198 | 199 | With those out of the way, you’re left with very, very few functions that still run CRC checks. But honestly, none of them are checking anything I’d consider remotely useful. And with the main protections already disabled, you could easily patch over the remaining checks using a simple hook like the ones I showed earlier. 200 | 201 | ## Conclusion 202 | Overall, disabling the anti-tamper checks only took me a couple of hours in total, though that was spread out over several days, just working on it here and there. The bulk of the time was spent doing reconnaissance on how CEG works internally. 203 | **Importantly, this doesn’t mess with any of the anti-piracy protections**. I stayed away from that on purpose. 204 | There were a ton of smaller techniques and analysis methods I used along the way that I either don’t remember clearly or don’t care to detail here, since they’re kind of outside the scope of this write-up anyway. 205 | I’ll probably revisit this again at some point just for fun, and maybe try coming up with a completely different solution. 206 | 207 | Also, since CEG is entirely usermode, it makes for a really fun and approachable playground if you’re interested in getting into reverse engineering or tamper protection bypassing in games. No kernel-mode trickery needed, just you, your debugger, and a lot of curiosity. 208 | 209 | If you would like to look at the final product source code, see [anti-anti-tamper.cpp](https://github.com/Rattpak/CEG-Anti-Tamper-Analysis/blob/f1bfb4025c7a1caa929053bea675d9c86b66b622/code/anti-anti-tamper.cpp) 210 | --------------------------------------------------------------------------------