Up a Stream
5 minutos de lectura
Se nos proporciona un archivo JAR llamado stream.jar
y un output.txt
:
$ cat output.txt
b71bO12cO156O6e43Od8O69c3O5cd3O144Oe4O6e43O37cbOf6O69c3O1e7bO156O3183O69c3O6cO8b3bOc0O1e7bO156OfcO50bbO69c3Oc0O102O6e43OdeOb14bOc6OfcOd8Ob71bO12cO156O6e43Od8O69c3O5cd3O144Oe4O6e43O37cbOf6O69c3O1e7bO156O3183O69c3O6cO8b3bOc0O1e7bO156OfcO50bbO69c3Oc0O102O6e43OdeOb14bOc6OfcOd8Ob71bO12cO156O6e43Od8O69c3O5cd3O144Oe4O6e43O37cbOf6O69c3O1e7bO156O3183O69c3O6cO8b3bOc0O1e7bO156OfcO50bbO69c3Oc0O102O6e43OdeOb14bOc6OfcOd8Ob71bO12cO156O6e43Od8O69c3O5cd3O144Oe4O6e43O37cbOf6O69c3O1e7bO156O3183O69c3O6cO8b3bOc0O1e7bO156OfcO50bbO69c3Oc0O102O6e43OdeOb14bOc6OfcOd8Ob71bO12cO156O6e43Od8O69c3O5cd3O144Oe4O6e43O37cbOf6O69c3O1e7bO156O3183O69c3O6cO8b3bOc0O1e7bO156OfcO50bbO69c3Oc0O102O6e43OdeOb14bOc6OfcOd8O
Si ejecutamos el JAR, tenemos un resultado diferente:
$ java -jar stream.jar
3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O
Descompilación de Java
Tendremos que descompilar el archivo JAR para obtener un código fuente en Java legible. Podemos ir a www.javadecompilers.com y seleccionar CFR como descompilador. Luego, tendremos este código fuente:
/*
* 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();
}
}
Está utilizando programación funcional con streams, lo cual significa que aplica funciones a todos los elementos a la vez (map), o aplica funciones de reducción (reduce).
Mejoras
Lo podemos formatear un poco. Nótese que peek
no aplica ninguna función al stream. Por tanto, podemos quitar las llamadas a peek
, y entonces, waterlog
y hydrate
no se usan para cifrar el texto de entrada. Al final nos quedamos con este código:
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(s: "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(identity: "", (s1, s2) -> s2 + s1)
.chars()
.mapToObj(n -> Character.valueOf((char) n))
.collect(Collectors.toList())
.stream()
.map(c -> c.toString())
.reduce(String::concat)
.orElse(other: "")
.chars()
.mapToObj(n -> n)
.collect(Collectors.toList())
.stream()
.map(Challenge::moisten)
.map(Challenge::drench)
.map(Challenge::dilute)
.map(Integer::toHexString)
.reduce(identity: "", (s1, s2) -> s1 + s2 + "O")
.repeat(count: 5)
);
}
private static Integer moisten(int n) {
return (int) (n % 2 == 0 ? (double) n : Math.pow(n, b: 2.0));
}
private static Integer drench(Integer n) {
return n << 1;
}
private static Integer dilute(Integer n) {
return n / 2 + n;
}
}
Y la salida se queda igual:
$ java Challenge.java
3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O3b13O3183Oe4Od2O
Análisis del algoritmo
Nótese que la salida se repite 5 veces:
$ java Challenge.java | wc -c
81
$ java Challenge.java | fold -sw 16
3b13O3183Oe4Od2O
3b13O3183Oe4Od2O
3b13O3183Oe4Od2O
3b13O3183Oe4Od2O
3b13O3183Oe4Od2O
Podemos aprovecharnos de peek
para imprimir resultados intermedios del proceso de cifrado:
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(identity: "", (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(other: "")
.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(identity: "", (s1, s2) -> s1 + s2 + "O")
.repeat(count: 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
Funciones inversas
Entonces vemos que lo único que necesitamos es invertir las funciones dilute
, drench
y moisten
. Y la salida final para cada carácter se junta con caracteres O
(no es un cero). Las funciones inversas son sencillas:
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
devuelve el mismo número si es par, y el cuadrado del número si es impar. Por tanto,unmoisten
devolverá el mismo número si es par y la raíz cuadrada si es impar (el cuadrado de un número impar sigue siendo impar)drench
desplaza el número un bit a la izquierda, por lo queundrench
desplazará el número un bit a la derechadilute
devuelve $n + \frac{n}{2} = \frac{3n}{2}$, por lo queundilute
tiene que devolver $\frac{2n}{3}$
Entonces, para descifrar el texto de salida, tenemos que coger una de las cinco repeticiones, dividirla por los caracteres O
y aplicar las funciones inversas. Luego, concatenamos los resultados en orden inverso:
public static void main(String[] args) {
if (args.length != 1) {
System.out.println(x: "Usage: java Solve.java <output-string>");
System.exit(status: 1);
}
System.out.println(Solve.reverseDunkTheFlag(args[0]));
}
private static String reverseDunkTheFlag(String s) {
return Arrays.asList(s.substring(4 * s.length() / 5).split(regex: "O"))
.stream()
.map(h -> Integer.valueOf(h, radix: 16))
.map(Solve::undilute)
.map(Solve::undrench)
.map(Solve::unmoisten)
.map(n -> Character.toString((char) (int) n))
.reduce(identity: "", (s1, s2) -> s2 + s1);
}
Y así, podemos obtener el mensaje "FLAG"
del resultado anterior:
$ java Solve.java $(java -jar stream.jar)
FLAG
Flag
Y entonces, podemos descifrar la salida original para obtener la flag:
$ java Solve.java $(cat output.txt)
HTB{JaV@_STr3@m$_Ar3_REaLlY_Hard}
El código completo se puede encontrar aquí: Solve.java
.