2024-03-15

Nullcon CTF Writeups

CTF Writeups for Nullcon CTF

Jeopardy CTF with CTF0.

Challenges I solved

Misc - Timecode

Java Challenge, Code provided.

The Challenge prints 6 numbers and wants you to input these 6 numbers again, but when you do that it "misinterprets" them in a weird way, and tells you, that you gave back the "wrong" numbers ... kinda weird.

> nc 52.59.124.14 5015
Registered as user 73e5d265-24bd-4d7d-a736-4ce50d15ebaa

New Challenge (2024-03-15T07:41:15.617Z)
47 11 07 8 857 16

47 11 07 8 857 16
'114' is not equal to '47'
'88' is not equal to '11'
'78' is not equal to '07'
'19' is not equal to '8'
'39' is not equal to '16'
Challenge failed.
Connection closed.

Looking at the source code, the players input gets handled like that, it first parses the user input with "parseInt" and then converts it to an Integer-Object via Casting (Instead of just using Integer.valueOf(n))

After that it compares the input to the sent output, if its the same you pass the challenge, if not the connection closes. You have to survive 10 challenges in succession.

1for(String field : fields) {
2
3 String comparedObj = null;
4
5 try {
6 //After cast 11->72 cast from int to Integer makes something weird based on ?
7 comparedObj = String.valueOf((Integer) Integer.parseInt(field));
8 } catch(NumberFormatException nfe) {
9 this.out.println(String.valueOf(field) + " is not an Integer!");
10 this.bye();
11 }
12
13 while(comparedObj.length() < pair.getFirst()[i].length()) {
14 comparedObj = "0" + comparedObj;
15 }
16
17 if(!(comparedObj.equals(pair.getFirst()[i]))) {
18 out.println("'" + comparedObj + "' is not equal to '" + pair.getFirst()[i] + "'");
19 success = false;
20 }
21
22 i++;
23 }

After debugging it some REALLY weird happens at line 7, after casting the int primitive to an Integer, the Integer holds an entirely different value, leading the following comparison to always fail.

Weird casting behavior

After double checking, if thats some weird casting behavior, I got reminded how Autoboxing in Java works, at least for Strings. To save resources, Java has a so called "String-Pool".

Java String and Integer Pool

Every time you create a String like this:

String foo  = "bar"
String foo2 = "bar"

Java does a lookup into its String-Pool. If the same String is already present inside, just a reference to this one gets passed back.

String Pool in Java

That saves some heap space, and all Strings inside the String-Pool can be compared with "==", because all share the same reference.

To force Java to not place a String on the String-Pool, just instantiate the String with the "new" Keyword:

String foo  = new String("bar")

Apparently Java has the same mechanism for Integers as well, every time you do:

Integer test = 3

Between -128 and 127, Java references the Integer reference to an Object in a so called "Integer Cache".

IntegerCache

The objects reference is received via a simple Lookup:

    @HotSpotIntrinsicCandidate
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

For Example: To get the Integer-Object for the int "0", the 128th Entry inside the Array gets returned.

That exact lookup table got sneakily changed when setting up the challenges, thus resulting in getting arbitrary numbers when autoboxing ints to Integers:

Tampering the IntegerCache

At first the IntegerCache needs to be Public, thats achieved with some reflection:

    //Makes Integer cache public
    private static Integer[] getArr() throws Exception {
        //java.lang.Integer$IntegerCache
        Class<?> c = Class.forName(ObfuscationUtil.decrypt(Constants.C));
        //cache
        Field f = c.getDeclaredField(ObfuscationUtil.decrypt(Constants.F));
        f.setAccessible(true);
        // Return Integercache and to weird stuff with it
        return (Integer[]) f.get(c);
    }

Afterwards the Lookup-Array-Content gets overwritten with a previously generated Array with pseudo random numbers based on a certain timestamp:

        //Overwrites data in integer value cache from 128 to 255 with content of "second" from 0 to 255
        for(int i = Constants.SIZE; i < arr.length; i++) {
            arr[i] = new Integer(pair.getSecond()[i - Constants.SIZE]);
        }

After doing that the user-input-parsing logic starts ... resulting in wrong numbers due to the cast from int to Integer.

For Example: User Inputs 10, now Java looks up the value in the 138th. Array-Position (128+10) and finds: 112. So your 10 just became a 112 and the comparison afterwards fails.

Loopup Table with random input

Getting the Flag

The Integer-Array that gets passed to the Integer-Cache is not entirely random, the logic uses the current unix timestamp as seed and print the one we need to use inside the nc session:

Challenge Output

With that timestamp you can call the "get_mapping" function locally to generate the same Array the server generated too, and so be able to lookup what numbers you have to type into the challenge prompt to get the "correct" number after autoboxing, and so be able to pass the check:

Index Lookup

I wrote a simple unittest for this and just copy pasted the challenge input/output 10 times:

    public void testfind123() {
        //Datetime from challenge prompt
        Instant e = LocalDateTime.parse("2024-03-14T16:41:03.966").toInstant(ZoneOffset.UTC);
        //Output from Challenge prompt
        String input = "63 41 70 16 6 943";
        List<Integer> input_list = Arrays.stream(input.split(" ")).map(Integer::valueOf).collect(Collectors.toList());
        Pair mapping = RngUtil.getMapping(e);
        String win=input_list.stream().map(nr-> {
            Integer.valueOf(nr) > 128 ?
            nr.toString() :
            String.valueOf(Arrays.asList(mapping.getSecond()).indexOf(nr.toString()))
        }).collect(Collectors.joining(" "));

        System.out.println(win);
    }

Flag:

Challenge Output

Failed attempt

I was blind and haven't seen that the timestamp was provided via challenge prompt, so I thought I needed to time my request, because at certain times (every 64 Milliseconds), the Integer array got correctly ordered by the algorithm:

1 public static Pair getMapping(Instant timestamp) {
2 long time = timestamp.toEpochMilli();
3 //long time = 1710423289920L;
4
5 //just rng numbers randomly generated
6 List<String> challenge = challenge(String.valueOf(time));
7
8 // sometimes here is ascending order
9 String[] mapping = new String[Constants.SIZE];
10
11 for(int i = 0; i < Constants.SIZE; i++) {
12 int diff = 0;
13
14 // Wenn timmemod ==64, dann zählt es hoch
15 long timmemod = time % 128;
16 int e = 1337*42;
17
18 int index = Math.abs((int) timmemod * (i + 1) * e ) % Constants.SIZE;
19 while(!(mapping[(index + diff) % Constants.SIZE] == null)) {
20 diff += 1;
21 }
22 mapping[(index + diff) % Constants.SIZE] = String.valueOf(i);
23 }
Every time 'time % 128' was 64, the underlying code would generate an ordered Integer array

I tried to time every "sendline()" prompt in pwntools, but sub 1ms packet travel without jitter to survive the 10 challenges is kinda impossible.

I was close giving up, but after seeing the timestamp in the nc prompt once again it make "click", and I had to slam my head onto my desk a few times 🫠 .