This CTF report is divided as follows:
The goal of this challenge is to retrieve a key stored inside a binary that usually run on a server. One way to get it is to verify a certificate with admin rights.
To help you, an inside man stole:
- the binary ('serma_challenge')
- a part of the code ('extract.c') that seems to be the function that performs the certificates verification
- a valid certificate ('toto.cert') with user rights (admin=0 instead of admin=1)
The purpose of this challenge is to evaluate your way to solve this problem (even if you don't succeed it), so please write everything you tried in your report.
When executed, the binary serma_challenge starts a server. Here's the view in a terminal, after adding execution mode to the binary file (chmod +x):
It tolds us that a server has been started, probably on a local port.
To know on which port the server is linked, we can analyse all open ports with the command lsof -i -P -n :
It shows that this server is on the port 1337.
We can establish a connection to this port with telnet localhost 1337 and send commands to the server. Here's the returned data from server after sending 'help' command (as sugested):
We now know the different commands available:
- help
- verify
- exit
On the server's side, it just display the command received in hexadecimal, with an end sequence (|d|a|):
This section describes the steps I have been through in the chronological order.
Here's the content of the certificate:
user=toto
admin=0
sig=546f2c57cfb33c9bb7277dd041ab0f8764e68437b6ef2153301712b9ec78d91f
It said that if we had a certificate with admin rights, we could retrieve the key from the server. To have such a certificate, the value admin should be equal to 1. But as it is signed, hard writing admin=1 will not work (because the signature will not match).
💡 Idea : look into the
extract.cfile to analyze how the certificate is verified, and try to find a way to make it accept a 'false admin certificate'
When sending the verify command, the server answers Cmd locked. This behavior is due to the certificate verification function described in extract.c: the verification function is disabled by a flag (verify_cmd_locked), and forbid us to go any further in the certificate verification procedure.
By analyzing the file serma_challenge with Ghidra, it turns out that another command is available: the command unlock. By trying this command we get two different answers from the server:
Wrong cmd format (expected format: unlock XXXXXXXX | 0xA5 XXXXXXXX)Wrong PIN (0)
This second answer is get if the command respect the following formats:
unlockXXXXXXX(the command followed by 7 characters, most likely a space and 6 characters)- or
unlockXX
❓ Remark: Is accepting this short-version command
unlockXXan unexpected behavior ? Or is it a feature that we should exploit ?
This function does the following (from re-assembled code):
void unlock(int socket_conn,int lenght_data_received)
{
[...]
if ((lenght_data_received != 0xf) && (__s = PTR_s_Wrong_cmd_format_(expected_forma_00105130, lenght_data_received != 10)) {
LAB_00101cdc:
__n = strlen(__s);
write(socket_conn,__s,__n);
return;
}
iVar1 = (uint)(lenght_data_received == 0xf) * 5;
if ((int)DAT_001050d0 == (uint)(byte)(&input_buff_slot)[iVar1 + 2]) {
if ((int)DAT_001050d1 == (uint)(byte)(&input_buff_slot)[iVar1 + 3]) {
if ((int)DAT_001050d2 == (uint)(byte)(&input_buff_slot)[iVar1 + 4]) {
if ((int)DAT_001050d3 == (uint)(byte)(&input_buff_slot)[iVar1 + 5]) {
if ((int)DAT_001050d4 == (uint)(byte)(&input_buff_slot)[iVar1 + 6]) {
if ((int)DAT_001050d5 == (uint)(byte)(&input_buff_slot)[iVar1 + 7]) {
if ((int)DAT_001050d6 == (uint)(byte)(&input_buff_slot)[iVar1 + 8]) {
cVar6 = '\a';
if ((int)DAT_001050d7 == (uint)(byte)(&input_buff_slot)[iVar1 + 9]) {
verify_cmd_locked = 0;
__s = PTR_s_Cmd_unlocked_00105140;
goto LAB_00101cdc;
}
[...]
}It behavior is quite simple, it verifies that the length of the given data is 15 or 10, and if not it prints an error message.
If this first step is passed, it checks if the input data correspond to pre-defined values (DAT_001050dX) and if so, it toggles the flag verify_cmd_locked to 0 before printing the message Cmd unlock.
⚠️ MistakeAs it performs 8 checks, I first assumed that it expect an input data constructed as follows:
unlockcommand (6 characters)- separation element, can be a space (1 character)
- PIN with 6 characters
- final characters
0xdand0xa(2 characters)But the characters
0xdand0xawere only due to telnet, that sends the command to the server when I typedreturn. By using a python script, I can construct myself the structure of the command, and send a 8-long PIN (which is more likely what the server expects)
Anyway, the input unlockXX seems not to be the expected input.
Here are the pre-defined values given by Ghidra:
DAT_001050d0 = 96h
DAT_001050d1 = 97h
DAT_001050d2 = 93h
DAT_001050d3 = 96h
DAT_001050d4 = 94h
DAT_001050d5 = 97h
DAT_001050d6 = 93h
DAT_001050d7 = 98h
The expected data seem to be the following:
|75|6e|6c|6f|63|6b|20|96|97|93|96|94|97|93|98|
💡 Idea : Use a script to send these specific hexa values to the server
I have tried to unlock the verify command by sending specific hexa values to the server. I sent the command unlock \x96\x97\x93\x96\x94\x97\x93\x98 but it didn't work. It looks like the values described in the previous section are not the exact values wanted by the server.
💡 Idea : The input buffer is pre-processed somewhere before the PIN is verified, or the values given by Ghidra are obfuscated in the code.
Two possibilities :
- reversing the code more deeply to find any modification applied to the input buffer
- using a debugger step by step to find any modification applied to the input buffer
💡 Other approach : brute-forcing the PIN
I chose to try a brute-forcing method. Assuming that the PIN only uses alpha-numerical characters (both with capital and lowercase letters), there is 65^8 possibilities, which is to much to test them all.
But thanks to the values given by Ghidra, I can make another assumption:
Let's call the PIN's characters values (in ASCII) with letters: ABCDEFGH. If the obfuscating method is linear, the values from Ghidra enable me to say that:
- the lower value is C (and G because C = G)
- A = D = C+3
- B = F = C+4
- E = C+1
- H = C+5
- G = C
This gives me a pattern that makes the brute-forcing tests a lot faster. With a python script, I tested all the possibilities based on these assumptions and I found the correct PIN: DEADBEAF 🥳
After having unlocked the verify command, the servers now answers Wrong certificate format (to the command verify).
From the source code (and a string analysis with Ghidra), we can get the execution graph of the certificate verification procedure (if the mermaid code is not correctly interpreted, you can find the graph in the folder img of this repo):
flowchart TD
Start((Start))
%% ----- MAIN CHECK -----
%% Boxes
VerifyCmdCheck{"Is verify cmd locked ?"}
PrintCmdIsLocked["Return : 'Cmd lock'"]
%% Links
Start --> VerifyCmdCheck
VerifyCmdCheck -->|Yes| PrintCmdIsLocked
VerifyCmdCheck -->|No| CheckSubstrUsr
%% ----- CERTIFICATE FORMAT VERIFICATION -----
subgraph Certificate Format Verification
%% -- Substring identification --
%% Boxes
CheckSubstrUsr{"Is 'user=' in certificate ?"}
SaveOfstUsr["offset_user = strlen(entryBuff) - strlen('user=')"]
CheckSubstrAdm{"Is 'admin=' in certificate ?"}
SaveOfstAdm["offset_admin = strlen(entryBuff) - strlen('admin=') \n admin_rights = entryBuff[offset_admin + 6]"]
CheckSubstrSig{"Is 'sig=' in certificate ?"}
SaveOfstSig["offset_sig = strlen(entyBuff) - strlen('sig=')"]
%% Links
CheckSubstrUsr -->|Yes| SaveOfstUsr --> CheckSubstrAdm
CheckSubstrAdm -->|Yes| SaveOfstAdm --> CheckSubstrSig
CheckSubstrSig -->|Yes| SaveOfstSig --> CheckOfstUsr
%% -- Check offset values --
%% Boxes
CheckOfstUsr{"offset_user = 7 ?"}
CheckOfstAdm{"offset_admin <= 21 ?"}
CheckOfstSig{"offset_sig = offset_admin + 8 ?"}
%% Links
CheckOfstUsr -->|Yes| CheckOfstAdm
CheckOfstAdm -->|Yes| CheckOfstSig
end
%% -- SIGNATURE VERIFICATION --
subgraph Signature Verification
%% Boxes
HMAC["DO HMAC"]
CheckLenHMAC{"Is given signature length correct\ncompared to HMAC result ?"}
Copy["Copy HMAC result to buffer 'result_str'\n+\nCopy given signature to buffer 'buff_to_cmp'"]
SignCheck{"result_str = buff_to_cmp ?"}
%% Links
CheckOfstSig -->|Yes| HMAC
HMAC --> CheckLenHMAC
CheckLenHMAC -->|Yes| Copy
Copy --> SignCheck
end
SignCheck -->|No| PrintWrSign
%% ----- ADMIN RIGHTS VERIFICATION -----
subgraph Admin Rights Verification
%% Boxes
CheckAdmRights{"Check value of admin_rights"}
%% Links
SignCheck -->|Yes| CheckAdmRights
end
%% ----- MESSAGES DISPLAYED -----
%% Boxes
PrintWrCertFormat["Return : 'Wrong certificate format'"]
PrintWrSign["Return : 'Wrong signature'"]
PrintUsrRights["Return : 'Valid signature (admin=0)'"]
PrintKey["Return : 'Congrats! Here's the private key : ' + key value"]
%% Links
CheckSubstrUsr -->|No| PrintWrCertFormat
CheckSubstrAdm -->|No| PrintWrCertFormat
CheckSubstrSig -->|No| PrintWrCertFormat
CheckOfstUsr -->|No| PrintWrCertFormat
CheckOfstAdm -->|No| PrintWrCertFormat
CheckOfstSig -->|No| PrintWrCertFormat
CheckLenHMAC ----->|No| PrintWrCertFormat
CheckAdmRights --->|0x30| PrintUsrRights
CheckAdmRights --->|0xfe| PrintKey
CheckAdmRights --->|other values| PrintWrCertFormat
End((End))
PrintCmdIsLocked --> End
PrintWrCertFormat --> End
PrintWrSign --> End
PrintUsrRights --> End
PrintKey --> End
This procedure is divided in three parts:
- The format verification
- The signature verification
- The admin rights verification
This parts ensures that the strings user=, admin= and sig= are present in the certificate.
Then, it does checks on the different offset values that can be interpreted as follows:
- the string
user=must start 7 characters after the beginning (checkoffset_user = 7). This means that there must be a SPACE, or any separation character, after the commandverify(because "verify" is only 6 characters long). - the string
admin=must not be 22 characters or more away from the beginning (checkoffset_admin <= 21). This means that the given username must be maximum 8 characters long ("verify"(6) + separation(1) + "user="(5) + username(8) + separation(1) = 21 characters)
❓ Remark : How is interpreted the separation character after the username ? Nothing seems to indicate that it cannot be part of this username...
- the string
sig=must be 8 characters after the beginning of the stringadmin=(checkoffset_sig = offset_admin + 8). This means that the value given afteradmin=should be on 1 character only (if we consider that it is followed by a separation character)
This parts checks is the given signature correspond to the given certificate signed.
⚠️ Mistake :I struggled a lot making the correct certificate being accepted by the server (see section "The certificate").
This was because I was sending the command
verify user=toto admin=0 sig=546f2c57cfb33c9bb7277dd041ab0f8764e68437b6ef2153301712b9ec78d91fand I had the resultWrong signature. But an hexdump analysis (picture below) on the given certificate shows that the separation characters should be\n(encoded0x0a) and not a SPACE. Indeed, the given signature is not valid if the separation characters are not respected.This gives an answer to the previous remark.
With the given certificate toto.cert, we should send the following command : verify user=toto\x0aadmin=0\x0asig=546f2c57cfb33c9bb7277dd041ab0f8764e68437b6ef2153301712b9ec78d91f. Note that the space character between verify and user= can be replaced by anything but a separation character should be present.
When receiving this command, the server answers Valid signature (admin=0)
By default, the admin rights are set to 0x30. To display the hidden key they must be set at 0xfe. To modify this value, I used a buffer overflow method.
During the verification procedure, the signature passed in the command is brutally copied into the array buff_to_cmp. At the beginning of extract.c file, we see that this array is declared right after admin_rights (code below).
void verify(int connexion, int size)
{
[...]
unsigned char cmp_sig = 1;
unsigned char admin_rights = 0x30;
unsigned char buff_to_cmp[WORKING_BUFFER_SIZE];
unsigned char result[WORKING_BUFFER_SIZE];
unsigned char result_str[WORKING_BUFFER_SIZE];
unsigned char to_send[256];
memset(buff_to_cmp, 0, WORKING_BUFFER_SIZE);
memset(result, 0, WORKING_BUFFER_SIZE);
memset(result_str, 0, WORKING_BUFFER_SIZE);
memset(to_send, 0, 256);
[...]By adding a lot off data after the signature, the copy will overwrite the value of admin_rights in the memory. Moreover, the signature comparison is made only on 64 bytes, so the additional data will not be considered by the verification procedure.
Testing this buffer overflow with zero padding, it turns out that it is the 16th byte that has an impact on the verification procedure's result (Wrong certificate format instead of Valid signature (admin=0), see the verification procedure graph).
Changing the 16th byte by 0xfe overwrites admin_rights with the expected value and gives us the flag : Congrats! Here's the private key :\n superprivatekey\n 🥳
After setting up the server-client communication, this CTF can be resolved by sending 2 commands :
unlock DEADBEAFto unlock theverifycommandverify user=toto\x0aadmin=0\x0asig=546f2c57cfb33c9bb7277dd041ab0f8764e68437b6ef2153301712b9ec78d91f\x0\x0\x0\x0\x0\x0\x0\x0\x0\x0\x0\x0\x0\x0\x0\xfewhich is the command to verify the given certificatetoto.cert(verify user=toto\x0aadmin=0\x0asig=546f2c57cfb33c9bb7277dd041ab0f8764e68437b6ef2153301712b9ec78d91f) but with additional data (150x00and 10xfe) to create a buffer overflow and change the value ofadmin_rightsto0xfe
The value of the key/flag is superprivatekey




