Coffee Invocation
hackthebox
Task: Stripped ELF64 binary that boots a JVM via JNI, loads embedded Java classes, and mutates boxed primitive caches to validate a flag. Solution: Carved Java classes from ELF, reversed native lookup tables and character remaps, detected Boolean.TRUE/FALSE swap to bypass Tinfoil decoy.
$ ls tags/ techniques/
Coffee Invocation — Hack The Box
Description
Our new crazy 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.
This is a native reverse-engineering challenge with embedded Java, not a web challenge and not a vulnerable Java application. The binary is a stripped ELF64 PIE that boots a JVM through JNI, loads hidden class files from its own image, sabotages Java runtime internals, and uses the modified semantics to validate the flag.
Analysis
The main hints are already in the title and strings:
- Coffee and javaluable strongly suggest Java.
- The ELF imports
JNI_CreateJavaVM, which confirms native-to-Java embedding. - Strings reference
Verify1,Verify2, staged verification messages, and theHTB{flag prefix.
So the correct mental model is:
- reverse the native ELF,
- recover the embedded Java classes,
- understand how JNI modifies Java behavior,
- invert both verification stages.
Extracting the hidden Java classes
binwalk would normally be a quick way to scan for embedded artifacts, but it was broken in this environment due to a Python imp import issue. Instead, I carved the classes directly by searching for the Java class magic 0xCAFEBABE.
The helper script extract_classes.py parses class-file structure so it can recover each full blob cleanly instead of dumping arbitrary bytes.
Relevant offsets found inside the ELF:
0x5180→Verify1.class0x5680→Verify2.class
The extraction logic is straightforward:
sig = b"\xca\xfe\xba\xbe" while True: i = data.find(sig, i) if i < 0: break end = parse_class_end(data, i) chunk = data[i:end]
After carving the files, javap -c -p gives enough bytecode to reconstruct the validation logic.
What the Java code does
Verify1
Verify1.main() takes two strings, checks that they have the same length, and then compares them character by character using:
compareByte(Byte.valueOf(source[i]), Short.valueOf(target[i]))
At first glance that looks pointless, because a Byte and a Short should compare equal only if their numeric values match. But the native code has already tampered with Java's boxed primitive internals and caches, so those wrapper objects no longer behave normally.
The hardcoded target string is:
~PL{A;PL{?;:=|PIC{HzP:A;~x
This stage validates the first 26 bytes of the password.
Verify2
Verify2.main() takes one string and processes it two characters at a time.
For each pair it:
- extracts a 2-character substring,
- runs it through
complexSort(pair, Boolean.TRUE), - compares the result with a 2-character slice from a transformed version of the constant string:
Cr1KD5mk0_uUzQYifaGVqlN2B3wvpgPtSx6Odo{8hjJLHy9IXb4RnWZ}TAFEsMce7
At the end it also checks whether the whole input equals "Tinfoil".
That final check is a trap: it is not the real password. Native code swaps Boolean.TRUE and Boolean.FALSE, so Java boolean-based logic no longer means what the decompiled source suggests.
JNI/native trickery
The real difficulty is in the ELF, not in the Java bytecode.
The native loader does all of the following before running the Java classes:
- hooks
java/lang/Shutdown.halt0so the program can observe Java exit codes without terminating immediately, - loads the carved classes through
DefineClass, - mutates Java
ByteandShortcache/object behavior, - mutates Java
Characterinternals using remap tables, - swaps
Boolean.TRUEandBoolean.FALSE.
This means the Java code must be read as logic running on a hostile JVM state, not on a normal one.
Exit-code staging
Verify2 uses System.exit(i + 3) after each successful pair. The native hook captures those exit codes and uses them as a progress channel. When the expected sequence is reached, native state advances until the final stage succeeds.
So the staged exits are not failure paths; they are part of the intended verification protocol.
Solution
Reversing stage 1 (Verify1)
Native code uses two 256-byte lookup tables located at virtual addresses:
0x74c00x75e0
Once Byte and Short are mutated, the comparison effectively becomes:
byte_map[source_char] == short_map[target_char]
Since the target string is known, we can invert the first map and recover the source bytes directly:
byte_map = rd.read_va(0x74C0, 256) short_map = rd.read_va(0x75E0, 256) inv_byte_map = {value: idx for idx, value in enumerate(byte_map)} part1 = bytes(inv_byte_map[short_map[ord(ch)]] for ch in TARGET1)
This reconstructs the first 26 characters of the password.
Reversing stage 2 (Verify2)
This stage is more subtle.
Native code stores pointers to 13 successive 127-byte character remap tables at virtual address 0x85c0. Each pair of password characters is verified under a different remapping.
For pair index i:
- the candidate pair is remapped through table
i, - the long target string is remapped through the same table,
- the remapped target is sorted,
- bytes
2*i : 2*i+2of that sorted string become the expected output for the remapped pair.
So each pair can be solved independently by inverting the corresponding character map.
table_ptrs = [struct.unpack("<Q", rd.read_va(0x85C0 + i * 8, 8))[0] for i in range(13)] for i, ptr in enumerate(table_ptrs): char_map = rd.read_va(ptr, 127) inv_char_map = {value: idx for idx, value in enumerate(char_map)} mapped_target = "".join(chr(char_map[ord(ch)]) for ch in TARGET2) expected_pair = "".join(sorted(mapped_target))[2 * i:2 * i + 2] part2.extend(inv_char_map[ord(ch)] for ch in expected_pair)
That yields the remaining 26 characters.
Why Tinfoil is a decoy
The decompiled Java suggests the final password should be Tinfoil:
if (!verifyPassword(input, "Tinfoil").booleanValue()) { System.exit(2); }
Normally that would reject every string except Tinfoil. But native code swaps the singleton objects behind Boolean.TRUE and Boolean.FALSE, which inverts the meaning of the boxed result returned by:
Boolean.valueOf(source.equals(target))
As a result:
input == "Tinfoil"produces the wrong logical outcome,input != "Tinfoil"is what the success path actually expects.
So Tinfoil is there to mislead anyone who only decompiles the Java and ignores the native runtime tampering.
Recovering the real password and flag
Combining both recovered halves gives the 52-byte password:
1_c4nt_c4ptur3_fl4g5_unt17_1v3_h4d_a1l_my_0xCAFEBABE
The binary then prints that password as the flag suffix in the format HTB{<suffix>}.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [reverse][Pro]Challenge7— tamuctf
- [reverse][Pro]Huffpuff— spbctf
- [reverse][free]cf madness— pingctf2026
- [reverse][free]roulette— umdctf
- [reverse][Pro]Reverse Me— taipanbyte