Up a Stream
5 minutes to read
We have a JAR file called stream.jar
and an output.txt
:
$ cat output.txt
b71bO12cO156O6e43Od8O69c3O5cd3O144Oe4O6e43O37cbOf6O69c3O1e7bO156O3183O69c3O6cO8b3bOc0O1e7bO156OfcO50bbO69c3Oc0O102O6e43OdeOb14bOc6OfcOd8Ob71bO12cO156O6e43Od8O69c3O5cd3O144Oe4O6e43O37cbOf6O69c3O1e7bO156O3183O69c3O6cO8b3bOc0O1e7bO156OfcO50bbO69c3Oc0O102O6e43OdeOb14bOc6OfcOd8Ob71bO12cO156O6e43Od8O69c3O5cd3O144Oe4O6e43O37cbOf6O69c3O1e7bO156O3183O69c3O6cO8b3bOc0O1e7bO156OfcO50bbO69c3Oc0O102O6e43OdeOb14bOc6OfcOd8Ob71bO12cO156O6e43Od8O69c3O5cd3O144Oe4O6e43O37cbOf6O69c3O1e7bO156O3183O69c3O6cO8b3bOc0O1e7bO156OfcO50bbO69c3Oc0O102O6e43OdeOb14bOc6OfcOd8Ob71bO12cO156O6e43Od8O69c3O5cd3O144Oe4O6e43O37cbOf6O69c3O1e7bO156O3183O69c3O6cO8b3bOc0O1e7bO156OfcO50bbO69c3Oc0O102O6e43OdeOb14bOc6OfcOd8O
If we run the JAR file, we have a different output:
$ java -jar stream.jar
3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O
Java decompilation
We will need to decompile the JAR file and obtain a readable Java source file. We can go to www.javadecompilers.com and select CFR as decompiler. Then, we will have this source code:
/*
* Decompiled with CFR 0.150.
*/
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class Challenge {
public static void main(String[] arrstring) {
String string = "FLAG";
Challenge.dunkTheFlag(string).stream().forEach(System.out::println);
}
private static List<String> dunkTheFlag(String string3) {
return Arrays.asList(
string3.chars()
.mapToObj(n -> Character.valueOf((char)n))
.collect(Collectors.toList())
.stream()
.peek(c -> Challenge.hydrate(c))
.map(c -> c.toString())
.reduce("", (string, string2) -> string2 + string)
.chars()
.mapToObj(n -> Character.valueOf((char)n))
.collect(Collectors.toList())
.stream()
.map(c -> c.toString())
.reduce(String::concat)
.get()
.chars()
.mapToObj(n -> n)
.collect(Collectors.toList())
.stream()
.map(n -> Challenge.moisten(n))
.map(n -> (int)n)
.map(Challenge::drench)
.peek(Challenge::waterlog)
.map(Challenge::dilute)
.map(Integer::toHexString)
.reduce("", (string, string2) -> string + string2 + "O")
.repeat(5)
);
}
private static Integer hydrate(Character c) {
return c.charValue() - '\u0001';
}
private static Integer moisten(int n) {
return (int)(n % 2 == 0 ? (double)n : Math.pow(n, 2.0));
}
private static Integer drench(Integer n) {
return n << 1;
}
private static Integer dilute(Integer n) {
return n / 2 + n;
}
private static byte waterlog(Integer n) {
n = ((n + 2) * 4 % 87 ^ 3) == 17362 ? n * 2 : n / 2;
return n.byteValue();
}
}
It is using functional programming with streams, which means that it applies a function to all the elements of the stream at once (map), or applies reduction functions (reduce).
Improvements
We can format it a bit more. Notice that peek
does not apply any function to the stream. Hence, we can remove the two calls to peek
, and therefore, waterlog
and hydrate
are not used to encrypt the input string. So we end up with this source code:
package challenge;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class Challenge {
public static void main(String[] args) {
Challenge.dunkTheFlag("FLAG").stream().forEach(System.out::println);
}
private static List<String> dunkTheFlag(String s) {
return Arrays.asList(
s.chars()
.mapToObj(n -> Character.valueOf((char) n))
.collect(Collectors.toList())
.stream()
.map(c -> c.toString())
.reduce("", (s1, s2) -> s2 + s1)
.chars()
.mapToObj(n -> Character.valueOf((char) n))
.collect(Collectors.toList())
.stream()
.map(c -> c.toString())
.reduce(String::concat)
.orElse("")
.chars()
.mapToObj(n -> n)
.collect(Collectors.toList())
.stream()
.map(Challenge::moisten)
.map(Challenge::drench)
.map(Challenge::dilute)
.map(Integer::toHexString)
.reduce("", (s1, s2) -> s1 + s2 + "O")
.repeat(5)
);
}
private static Integer moisten(int n) {
return (int) (n % 2 == 0 ? (double) n : Math.pow(n, 2.0));
}
private static Integer drench(Integer n) {
return n << 1;
}
private static Integer dilute(Integer n) {
return n / 2 + n;
}
}
And the output remains the same:
$ java Challenge.java
3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O
Algorithm analysis
Notice that the output is repeated 5 times:
$ java Challenge.java | wc -c
81
$ java Challenge.java | fold -sw 16
3b13O3183Oe4Od2O
3b13O3183Oe4Od2O
3b13O3183Oe4Od2O
3b13O3183Oe4Od2O
3b13O3183Oe4Od2O
We can take advantage of peek
to print the intermediate results of the encryption process:
private static List<String> dunkTheFlag(String s) {
return Arrays.asList(
s.chars()
.mapToObj(n -> Character.valueOf((char) n))
.collect(Collectors.toList())
.stream()
.map(c -> c.toString())
.reduce("", (s1, s2) -> s2 + s1)
.chars()
.mapToObj(n -> Character.valueOf((char) n))
.peek(x -> System.out.println("First reduce: " + x))
.collect(Collectors.toList())
.stream()
.map(c -> c.toString())
.reduce(String::concat)
.orElse("")
.chars()
.peek(x -> System.out.println("Second reduce: " + x))
.mapToObj(n -> n)
.collect(Collectors.toList())
.stream()
.map(Challenge::moisten)
.peek(x -> System.out.println("moisten: " + x))
.map(Challenge::drench)
.peek(x -> System.out.println("drench: " + x))
.map(Challenge::dilute)
.peek(x -> System.out.println("dilute: " + x))
.map(Integer::toHexString)
.peek(x -> System.out.println("toHexString: " + x))
.reduce("", (s1, s2) -> s1 + s2 + "O")
.repeat(5)
);
}
$ java Challenge.java
First reduce: G
First reduce: A
First reduce: L
First reduce: F
Second reduce: 71
Second reduce: 65
Second reduce: 76
Second reduce: 70
moisten: 5041
drench: 10082
dilute: 15123
toHexString: 3b13
moisten: 4225
drench: 8450
dilute: 12675
toHexString: 3183
moisten: 76
drench: 152
dilute: 228
toHexString: e4
moisten: 70
drench: 140
dilute: 210
toHexString: d2
3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O
Reversing functions
So we see that the only thing we need to reverse are functions dilute
, drench
and moisten
. And the final output for each character is joined with O
(not a zero character). The inverse operations are easy:
private static Integer unmoisten(int n) {
return (int) (n % 2 == 0 ? (double) n : Math.sqrt(n));
}
private static Integer undrench(Integer n) {
return n >> 1;
}
private static Integer undilute(Integer n) {
return n * 2 / 3;
}
moisten
returns the same number if it is even, and the square of the number if it is odd. Hence,unmoisten
returns the same number if the number is even and the square root if it is odd (the square of an odd number is still an odd number)drench
shifts the number one bit to the left, soundrench
shifts the number one bit to the rightdilute
returns $n + \frac{n}{2} = \frac{3n}{2}$, soundilute
must return $\frac{2n}{3}$
So, to decrypt the output string, we need to take one of the five repetitions, split it by the O
characters and apply the inverse functions. Then, we need to concatenate the results in reverse order:
public static void main(String[] args) {
if (args.length != 1) {
System.out.println("Usage: java Solve.java <output-string>");
System.exit(1);
}
System.out.println(Solve.reverseDunkTheFlag(args[0]));
}
private static String reverseDunkTheFlag(String s) {
return Arrays.asList(s.substring(4 * s.length() / 5).split("O"))
.stream()
.map(h -> Integer.valueOf(h, 16))
.map(Solve::undilute)
.map(Solve::undrench)
.map(Solve::unmoisten)
.map(n -> Character.toString((char) (int) n))
.reduce("", (s1, s2) -> s2 + s1);
}
And so, we can get the "FLAG"
message from the encrypted output:
$ java Solve.java $(java -jar stream.jar)
FLAG
Flag
So, let’s decrypt the original output to get the flag:
$ java Solve.java $(cat output.txt)
HTB{JaV@_STr3@m$_Ar3_REaLlY_Hard}
The full solution source code is here: Solve.java
.