Our new conspiracy theorist intern has blocked everyone from the coffee machine because he saw that aliens were trying to steal the “out of the world” secret recipe. Your mission is to unveil the secrets that lie behind his profound madness and teach him a javaluable lesson.
Category: Reversing
Solvers: t0b1, lmarschk
TL;DR
This challenges was very nice but also hell of a ride. The main thing being done here is to use the Java Native Interface (JNI) to run a JVM from native C++ code. Then the behaviour of functions like Character.valueOf
or System.exit
is altered to obfuscate what is being done. In the end it uses several mappings to encode the flag in the binary.
Writeup
We get a binary called coffe_invocation
. Running the strings
tool shows nothing but Java stuff (and the coffee ascii art). The file
tool tells us that it is a 64-bit binary and that the symbols are stripped.
We execute the binary to see what is happening and see that Java is required to run the program. Thus we install Java and set the LD_LIBRARY_PATH
accordingly.
Now we can run the binary for the first time and get the following output.
We can see, that there is the [Redacted]
option. However choosing this options yields Can't access secret coffee without providing the password
. As we don’t know what the password might be, we decompile the bianry and find the main
function. (We already renamed some functions for clarity)
JNI Introduction
We find the first interesting function call. The JNI_CreateJavaVM
looks interesting. JNI is the “Java Native Interface” and the Oracle documentation describes it as follows.
The JNI is a native programming interface. It allows Java code that runs inside a Java Virtual Machine (VM) to interoperate with applications and libraries written in other programming languages, such as C, C++, and assembly.
That means, that this program, probably written in C++, uses a JVM to execute certain Java code and communicate with that. A typical call to that function looks like JNI_CreateJavaVM(&jvm, (void**)&env, &vm_args);
which is how we renamed the function parameters.
As we haven’t worked with JNI before, we search the internet for some basic tutorials, to find out how the original C++ code looks like. We find this tutorial that shows us some basics. Creating a VM looks like this.
JavaVM *jvm;
JNIEnv *jvm_env;
long flag = JNI_CreateJavaVM(&jvm, (void**)&jvm_env, &vmArgs);
Afterwards we can use the jvm_env
to find classes and methods and to call those methods directly from our native code like this (error handling is excluded here).
jclass jcls = jvm_env->FindClass("org/jnijvm/Demo");
jmethodID methodId = jvm_env->GetStaticMethodID(jcls, "greet", "(Ljava/lang/String;)V");
jstring str = jvm_env->NewStringUTF(10);
jvm_env->CallStaticVoidMethod(jcls, methodId, str);
Now that we have a basic understanding of the JNI we can continue reverse engineering the binary.
JNI function calls in decompiler
In the main function above we see a call to JavaPrintln
(we called it like that). By looking into that function we can find the things we saw in the tutorial.
This helps us understanding what happens in that function and coming to the conclusion, that this effectively calls Javas println
function. We can rename the variables accordingly, to give us a better understanding, as already done in the screenshot.
Sadly Ghidra does not know the JNI, which is why we can’t see any of the function calls on the jvm_env
. But we can use the Java docs again here that give us a function table of the JNINativeInterface
, which looks like this.
const struct JNINativeInterface ... = {
NULL,
NULL,
NULL,
NULL,
GetVersion,
DefineClass,
FindClass,
FromReflectedMethod,
...many more...
}
Each entry is 8 byte long, so jvm_env+0x30
must be the 7th entry. This is the FindClass
function which fits perfectly with our assumptions above. We can use that as a reference point. Whenever we see an offset to the jvm_env
we can find the corresponding function.
get_flag
function
If we choose the [Redacted]
option in the menu, the check for the program parameters is performed. This clearly shows us, that we have to pass the password as the parameter to the program via the CLI.
Continuing, the get_flag
function follows. We will immediately see why we called it like that.
It will execute the first verification, afterwards the second verification and if both passed with return code 0, the program prints the flag. Looking at the code we see, that the flag that is printed, is simply the parameter that we passed as a CLI argument. That means, if we find out what the checks do, we know how to make them pass and get the flag.
Verify1
We need to pass the first check first. Looking into the execute_verify1
function reveals a lot more and it is getting more and more interesting.
Shutdown re-mapping
The first thing being done is calling the following function.
void register_shutdown(long *jvm_env,undefined8 on_halt) {
undefined8 shutdown_cls;
_halt0_function_pointer = on_halt;
shutdown_cls = (**(code **)(*jvm_env + 0x30))(jvm_env,"java/lang/Shutdown",jvm_env);
(**(code **)(*jvm_env + 0x6b8))(jvm_env,shutdown_cls,&pt_halt0_struct,2);
return;
}
This calls the RegisterNatives
from the JNI. We can find the corresponding documentation. Using this function, we can register native methods. The register_shutdown
method changes the functionality of the halt0
and runAllFinalizers
function. The functionality of halt0
is changed to the function at the on_halt
pointer. The functionality of runAllFinalizers
is changed to do nothing at all, which means that no finalizers will be run.
halt0
is the native halt method, which is called after executing the System.exit(status_code)
function in a Java program. By modifying this behaviour, we modify how the program terminates, and what happens afterwards. Normally halt0
would terminate the VM.
In execute_verify1
the halt0
function is remapped to a function that stores the return code in a global variable. In the end of execute_verify1
this value is returned.
Value re-mapping
We find the following two lines right after the register_shutdown
call. Again, those functions are already renamed by us.
mapping = get_mapping(1);
remap_byte(jvm_env,mapping,mapping);
The get_mapping
function simply returns a pointer to an array of bytes. The specific array is chosen based on the given parameter. As those arrays are all equal in size, we assume that it is some kind of a mapping and called the function like that.
Then the remap_byte
function is called. It does the following.
We find our jvm again. Now certain functions are called. We again look at the Java docs about the Byte.valueOf
function and find the following.
Returns a Byte instance representing the specified byte value. If a new Byte instance is not required, this method should generally be used in preference to the constructor Byte(byte), as this method is likely to yield significantly better space and time performance since all byte values are cached.
That reveals, that the Byte.valueOf
function uses cached values. It returns a Byte object which is cached. We can change the value of that Byte object by using Object.value = some_value
. That way we can change the cached byte mapping! As we can see in the decompiled code in line 18, the SetByteField
function (offset 0x350) is called with value
and the returned byte in the line above. The mapping is clearly changed by the program! Sadly Ghidra does not show the mapping
parameter we know from the execute_verify1
function.
The cached values for the Short
objects are also remapped in the next step using another mapping.
Classes in memory
Then we find the following lines.
class_base_address = get_verify_class(0,&some_value);
local_50 = (**(code **)(*jvm_env + 0x28)) (jvm_env,"Verify1",0,class_base_address,some_value & 0xffffffff);
The get_verify_class
function catched us. Depending on the first parameter, it sets the class_base_address
differently to a address in the binary. Looking through the bytes at those addresses we find the following sequence <init> ... Code ... LineNumberTable ... compareByte ...
. This made us think that there might be a Java class in the binary. The second line in the code above proves that assumption. It calls the FindClass
function and passes class_base_address
.
The Java class must be inside the binary. By running binwalk
on the binary, we can indeed extract the compiled Java classes. This yields:
$ binwalk -D='.*' ./coffee_invocation
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 ELF, 64-bit LSB shared object, AMD x86-64, version 1 (SYSV)
16704 0x4140 Compiled Java class data, version 55.0
17984 0x4640 Compiled Java class data, version 55.0
Calling the class
Once the class is loaded, several logic is applied to prepare the call of the main
function of the Verify1
class.
The first 30 byte are taken from our CLI parameter using the Java String.substr(0,30)
call. This is stored in an array at position 0. Afterwards another string, which is stored in the binary is loaded. It is also 30 bytes long. This string is stored in the same array at position 1. Then the main
function of the Verify1
class is called with our array as parameter.
As we extracted the class file previously, we can decompile that to get the following code. The online tool javadecompiler.com turns out to be very valuable here. We choose CFR as the decompiler.
/*
* Decompiled with CFR 0.150.
*/
public class Verify1 {
private static boolean compareByte(Byte by, Short s) {
return by.byteValue() == s.shortValue();
}
public static void main(String[] arrstring) {
if (arrstring == null || arrstring.length != 2) {
System.out.println("Verifying requires source and target");
System.exit(1);
return;
}
String string = arrstring[0];
String string2 = arrstring[1];
if (string == null || string2 == null) {
System.out.println("Source and Target may not be null");
System.exit(2);
return;
}
if (string.length() != string2.length()) {
System.out.println("Source and Target don't have the same length");
System.exit(2);
return;
}
System.out.println("Verifying user is of terrestrial origin...");
for (int i = 0; i < string.length(); ++i) {
if (Verify1.compareByte(Byte.valueOf((byte)string.charAt(i)), Short.valueOf((byte)string2.charAt(i)))) continue;
System.out.println("=> User might be an alien!!!");
System.exit(3);
return;
}
}
}
We can see, that after each System.exit
call, a return
is called seperatly. As we’ve seen above, this is to properly terminate the program as the halt0
function no longer terminates the VM.
Solving Verify1
The logic is simple. It iterates over both strings we placed in the array above, s
being our input and s2
being the string from memory. If any byte does not match, the function returns. For our input string s
it uses the Byte.valueOf
function, which uses the changed cached values. For the predefined string it uses the Short.valueOf
function whose mapping has been changed as well.
Now we only have to reverse the mappings to get the correct first input. This is done by the following python script. (I omitted the mappings to save up space, you can find the whole code in solve.py)
def byte_str_to_array(s):
return [int(a, 16) for a in s.split(' ')]
def solve1():
bytes_mapping_str = '' # insert byte mapping from ghidra here
short_mapping_str = '' # insert short mapping from ghidra here
og_mapping = byte_str_to_array(og_mapping_str)
bytes_mapping = byte_str_to_array(bytes_mapping_str)
short_mapping = byte_str_to_array(short_mapping_str)
desired_str = '75 39 30 0c 70 30 20 6b 30 75 30 0c 6b 30 20 26 61 30 0c 26 70 0c 2b 30 0c 39 21 7a 6b 3a'
desired = byte_str_to_array(desired_str)
needed = []
for byte in desired:
needed.append(chr(bytes_mapping.index(short_mapping[byte])))
return ''.join(needed)
We get the output: th3_s3cr3t3_r3c1p3_1s_23_h0ur5
. Feeding that to the program shows:
Verifying user is of terrestrial origin...
Source may not be null
No coffee for you!
Hurray. We passed the check, as we didn’t got the output => User might be an alien!!!
.
After the Verify1
class is executed, the mappings of Byte and Short are returned to their normal mapping.
Verify2
Now that we solved the first verification check, we move on to the second. The second verification is very similar to the first one. Instead of changing the Byte and Short values, it is changing the values of the Characters. It also swaps the meaning of true
and false
, so true becomes false and false becomes true. Furthermore it takes the second 30 characters from our input string.
However, there is a difference here. In the beginning of the second verification, the register_shutdown
function is called again. This time the function that halt0
is mapped to is of more interest. The function stores the exit code in the global variable just like done in execute_verify1
and remaps the characters afterwards. This can be done up to 15 times. We remember that our halt0
function does not terminate the Java program.
We decompile the Verify2
class and find the following.
/*
* Decompiled with CFR 0.150.
*/
import java.util.Arrays;
import java.util.stream.Collectors;
public class Verify2 {
private static boolean compareByte(Byte by, Short s) {
System.out.println(by + " == " + s);
return by.byteValue() == s.shortValue();
}
private static String complexSort(String string, Boolean bl) {
Object[] arrobject = string.chars().mapToObj(n -> Character.valueOf((char)n)).toArray();
if (bl.booleanValue()) {
Arrays.sort(arrobject);
}
String string2 = Arrays.stream(arrobject).map(Object::toString).collect(Collectors.joining());
return string2;
}
private static Boolean verifyPassword(String string, String string2) {
return string.equals(string2);
}
public static void main(String[] arrstring) {
if (arrstring == null || arrstring.length != 1) {
System.out.println("Verifying requires source");
System.exit(1);
return;
}
String string = arrstring[0];
if (string == null) {
System.out.println("Source may not be null");
System.exit(1);
return;
}
if (string.length() % 2 != 0) {
System.out.println("Length must be even");
System.exit(1);
return;
}
System.out.println("Verifying user has authorization...");
for (int i = 0; i < string.length() / 2; ++i) {
String string2;
String string3 = string.substring(i * 2, i * 2 + 2);
String string4 = Verify2.complexSort(string3, true);
if (!string4.equals((string2 = Verify2.complexSort("Cr1KD5mk0_uUzQYifaGVqlN2B3wvpgPtSx6Odo{8hjJLHy9IXb4RnWZ}TAFEsMce7", false)).substring(i * 2, i * 2 + 2))) continue;
System.exit(i + 3);
}
if (!Verify2.verifyPassword(string, "Tinfoil").booleanValue()) {
System.out.println("Please enter the correct password");
System.exit(2);
}
}
}
Again we find some checks to make sure that our input string is long enough. This time, the input array of the main function only contains one string, the second 30 bytes of our input.
Afterwards we enter the loop that verifies our authorization. It takes two characters of our input string and calls complexSort
. That function simply maps each character to its value retrieved using valueOf
(remember, this mapping has been changed). If the boolean value of bl
is true, as the mapping has been swapped as well bl
must be false, the array is sorted.
The string4
, our two mapped bytes, are compared against the sorted string of Cr1KD5mk0_uUzQYifaGVqlN2B3wvpgPtSx6Odo{8hjJLHy9IXb4RnWZ}TAFEsMce7
after mapping each character. If that substrings match we call System.exit(i+3)
. Now the altered behaviour of halt0
comes to play. It remaps the character values, but does not halt the program. That way the loop will continue and compare the next two bytes of our input and the weird string.
Essentially, it is using 15 different character mappings, one for each pair of characters. Now we only have to reverse those mappings using the weird string and get the second part of our flag. We can solve it with the following python script. (I omitted the mappings to save up space, you can find the working code iin solve.py)
def chars_from_mapping(mapping):
l = mapping.split(' ')
char_mapping = [chr(int(a, 16)) for a in l]
return char_mapping
def complexSort(s, mapping):
mapped_chars = [mapping[ord(c)] for c in s]
sorted_chars = sorted(mapped_chars)
return ''.join(sorted_chars)
def solve2():
mappings = [] # insert byte strings from the 15 mappings here
desired = 'Cr1KD5mk0_uUzQYifaGVqlN2B3wvpgPtSx6Odo{8hjJLHy9IXb4RnWZ}TAFEsMce7'
secret_recipe = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz{}'
needed = []
for i in range(0,15):
mapping = chars_from_mapping(mappings[i])
secret = complexSort(desired, mapping)
c1 = secret[i*2]
c2 = secret[i*2+1]
c1_new = chr(mapping.index(c1))
c2_new = chr(mapping.index(c2))
needed.append(c1_new)
needed.append(c2_new)
return ''.join(needed)
Running the solve2
function gives us the second part of our flag. _str41ght_0f_r34d1ng_J4v4_d0cs
.
All together
To verify that we have the correct flag, we feed the complete string to the program. And after literally 23 hours straight of reading the Java docs, we get the ironic flag. What a challenge! HTB{th3_s3cr3t3_r3c1p3_1s_23_h0ur5_str41ght_0f_r34d1ng_J4v4_d0cs}