$ cat writeup.md…
$ cat writeup.md…
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.
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.
The main hints are already in the title and strings:
JNI_CreateJavaVM, which confirms native-to-Java embedding.Verify1, Verify2, staged verification messages, and the HTB{ flag prefix.So the correct mental model is:
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.classThe 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.
Verify1Verify1.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.
Verify2Verify2.main() takes one string and processes it two characters at a time.
For each pair it:
complexSort(pair, Boolean.TRUE),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.
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:
java/lang/Shutdown.halt0 so the program can observe Java exit codes without terminating immediately,DefineClass,Byte and Short cache/object behavior,Character internals using remap tables,Boolean.TRUE and Boolean.FALSE.This means the Java code must be read as logic running on a hostile JVM state, not on a normal one.
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.
Verify1)Native code uses two 256-byte lookup tables located at virtual addresses:
0x74c00x75e0Once 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.
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:
i,2*i : 2*i+2 of 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.
Tinfoil is a decoyThe 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.
Combining both recovered halves gives the 52-byte password:
REDACTED
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