TimeLock (https://www.algomachines.com/) is a freeware program being developed in order to facilitate secure temporary locking of files up to 10KB in size, with the unlocking being possible only during a time window specified by the user during initial locking.
In it’s current iteration (V3.3) locking requires the user to provide an input file, a password and the start and end date during which time the lockbox can be open. The time check is based on the bitcoin blockchain, as the program connects to several bitcoin nodes and queries the header files for recent blocks.
The TimeLock Challenges have been launched by the software creator in order to test the time check part of the encryption algorithm for vulnerabilities. This means that a lockbox is supplied, together with the password, and the goal is to open it before the start time that it has been designed for.
This challenge was the 11th to be launched (you can view all of them here, together with reports for the cracked challenges: https://www.algomachines.com/people).
Start time: 02/9/2020 00:00:00 UTC
End time: 03/9/2020 00:00:00 UTC
The current version is very similar to V3.2, with the major changes being in how the script text is sent to the interpreter and how the interpreter protects against debugging. The application also needs to be restarted for every unlocking attempt, which is unneeded in my opinion. It doesn’t do much to hinder cracking attempts (debuggers can restart an application instantly so crackers only get slowed down by about 2 seconds per unlocking attempt).
Since all the 3.x versions have so far been very similar, I will be referencing both V3.1 and V3.2 reports, since information gathered from both was used to come up with the crack for this one.
The addresses referenced in this report are Virtual Addresses (VA), rebased at 0x0, or File Offsets(FO).
Once again nothing has changed since the previous build, which means that the two file patches that worked previously will also get the job done here (see report for V3.1). Exact patching locations are at FO 21F3F8 (4 NOPs) and 43251 (2 NOPs).
Since the point of vulnerability for the previous 2 versions was the “magic_decode_function”, that’s the first place I looked at in the current version. For V3.3, “magic_decode_function” is at VA 83540.
Comparing to V3.2, we can note that the structure is very very similar. We still have function calls that seems to decode strings. I’ve spent quite a bit of time trying to deobfuscate what’s going on there but I didn’t turn up anything good. Not saying it’s impossible, but I found an easier way. Below is a very short example of how the functions look now (some names might be inaccurate).
Things to note: The WorkbenchLib::AddExternalFunction still uses the V3.2 string convention. In other words, in the above image, v12 still holds plaintext — namely — “Hash”. However, once all the external functions are linked to the interpreter, there’s a new function (called uncreatively Decrypt_string) instead of the old one. This one doesn’t leak any plaintext on the return, and I’ve tried to crawl inside of it with no luck as well.
Well okay, what can we do now? It’s important to remember here that we have the original (non-interpreter) script in the V3.1 binary, and we also have a complete source code leak of the script in V3.2. Of course, the first idea I had was that I could somehow modify the string that gets fed into the script buffer, just like the V3.2 crack, but I’m too dumb for that.
Instead, let’s take a look again at what the script that’s being run (most likely unchanged) does, using the source code gathered from V3.2:
double diff = C — Ctarget;
if (diff < 0)
c_64 = 1 — diff;
if (diff > 0)
c_64 = diff;
uint64 seed = t3 + c_64;
c_64 += t3 * t3;
Hash(c_64, seed, c_ret);
The above is only the final snippet, but the interesting part’s there. Basically, some math gets fed into 2 variables, c_64 and seed and then a hash is created using them. The hash is then compared to the hash calculated from the blockchain headers (we know this from V3.1).
But wait, the hashing function isn’t being run in the interpreter! It has to be present in the normal code, somewhere… If we can find it, we can just control the input parameters to it (c_64 and seed) and force it to spit out the hash that will decrypt our lockbox. So how can we find it?
Well, the IDA snippet I posted above has the function call WorkbenchLib::AddExternalFunction and we’ve already seen with the debugger that the decoded string for it is “Hash”. So a fair assumption is that it’s adding a reference of the hashing function to the interpreter. One of the parameters being passed is “sub_88270”, a pointer to a function. So probably that function is being run when the Hash(c_64, seed, c_ret); line is executed from the interpreter. Let’s look at the function.
For the most part there’s just more calls to interpreter functions, but what’s this? A single call that looks out of place…. At this point it’s worth going back to V3.1 to see how the hashing function was called there:
The params are identical, and if we try to explore the function, it looks the same. We’re in business!
Once I saw this, I quickly opened up V3.2 and proceeded to test out stuff there. Why? Because I already had a way to control the input into the function, and I wanted to see how bad input differs from good input.
So let’s look at V3.2 again. Unsurprisingly, the WorkbenchLib::AddExternalFunction for the Hash is in the same spot inside the magic_decode_function. Let’s fire up a debugger and place a breakpoint at VA 794B9 which is where the call to Hash resides. Open the V3.2 challenge (with password 28FDC1A8–7320–4885-B03E-85FC06DCD4A3) and wait for the bp to hit. Once we’re there, the seed will be in the R8 register, and its value should be seed=AEFD. The RCX register holds a pointer to the location of c_64 so if we browse the memory region at that location we find c_64=779B8811. These values are for the locked box.
Now let’s see the same variables, but this time for a lockbox that is unlockable. Remember to also keep the breakpoint at VA 794B9. Following the V3.2 report we can patch the script line double diff = C — Ctarget; into double diff = 0.25; and then we continue execution and our new breakpoint is hit. This time, seed=AEFC and c_64=779B8810. Okay, let’s do some quick maff.
uint_64 c_64 = diff or 1 - diff
uint_64 seed = t3 + c_64
c_64 += t3*t3seed_bad = AEFD
seed_good = AEFC
c_64_bad = 779B8811
c_64_good = 779B8810
Now, c_64 and seed are integers. And since diff had to be lower than 0.5, we can extract a rule for the “good” values (because rounding something lower than 0.5 will give 0):
seed_good = t3 + (diff_should_be_0) = t3
c_64_good = t3*t3 + (diff_should_be_0) = t3*t3
-> t3 = AEFC
-> t3*t3 = 779B8810
Now let’s test our hypothesis. We restart the unlocking process for V3.2 and don’t stop to change any script lines, only use a single bp at the Hash function call. Once it’s hit, we modify R8 from AEFD to AEFC and the value of the RCX pointer from 779B8811 to 779B8810. We can now continue execution and do this 2 more times (as usual, the decryption is being run 3 times). Boom, we just cracked Challenge V3.2 again!
Time to apply our newfound knowledge to Challenge V3.3.
Opening the lockbox
For TimeLock V3.3, the call to the Hash function is coming from VA 88359 so let’s put a breakpoint there. We can start the unlocking process, and once the code execution stops at our bp, we can read R8 and the value of the RCX pointer to get:
seed_bad = 9EFC3
c_64_bad = 62BC1BEF83
We know that c_64 = t3*t3, and seed = t3, so c_64 = seed * seed. If we do the math ourselves:
9EFC3 * 9EFC3 = 62BC43AE89
We’re way over the value the code is showing us. Meaning, “seed” is incorrect. But we knew that anyway. Let’s try to decrement it.
9EFC2 * 9EFC2 = 62BC2FCF04
9EFC1 * 9EFC1 = 62BC1BEF81 <---- CLOSE!
Okay, so it seems like in this case, seed = t3 +2, meaning that diff got rounded to 2. Since c_64 = t3 * t3 + diff we know we also have to subtract 2 from the c_64_bad which gives us the new values that should open the lockbox:
t3 = 9EFC1
seed_good = 9EFC1
c_64_good = 62BC1BEF81
Since we’re still at that breakpoint, let’s change R8 to 9EFC1 and the value of the RCX pointer to 62BC1BEF81. Continue execution and do this two more times. NOPE, didn’t work! Ugh, is our math wrong?
Well, at this point, it’s worth reading up the newest version’s patch notes closely, as they do mention:
Improved interpreter anti-debugging measures
Well I didn’t see any… oh, wait. Maybe the interpreter sees that we’re debugging it and messing with us. But, as we know from V3.1, all we care about is the Hash output, nothing else about the execution interests us. And we can check what hash gets returned to the main code (as in V3.1) from the magic_decrypt_function.
In V3.3, the return value can be found at VA 5DB75 in the RAX register (little side note, RAX always holds the return value of a function. Function parameters may also be modified if they’re passed via reference, like when using the out and ref keywords in a function call in c++ code). So set a bp at that location, but keep the old one as well. Once the first breakpoint is hit (the Hash call) modify the values again using what we obtained above and continue execution. We end up at the new breakpoint, and RAX is 0! There’s definitely something fishy going on.
But as I said, luckily for us, we only care about the Hash function output. The entire interpreter script could be skipped if wanted to, so long as we have the correct hash. And as the V3.1 call to the hash function shows:
the output gets stored in the 4th parameter, meaning R9. More precisely, R9 will hold a pointer to the hash.
So continue execution and you’ll end up at the Hash call again. Once again we change the values to the “good” ones, but this time instead of resuming execution, we just step over that call. At this point, the Hash function finished the number crunching, and R9 holds a pointer to the hash that was calculated. For reference, that hash should be E6DB12699A51BE6D.
Now we can resume execution and we end up again at the RAX register which is 0. This time, however, we’re prepared; we assign RAX the hash from above. Continue execution again, and we’re at the Hash call. We don’t even care about it anymore, it served it’s purpose. Continue execution. Finally, we’re seeing the empty RAX for the last time. Swap the 0 for the computed hash. Continue execution again. Boom! We got our decrypt!
This version proved to be much more of a challenge than the last. The interpreter’s code obfuscation seems (to me at least) quite strong, and I haven’t yet found a way to modify it using a debugger. Obviously, there’s now the issue that the Hash function itself can be abused. Concievably, the entire Hash function could be interpreted at runtime as well, even though it is quite long. Some built-in constants of the hash functions should be changed, since otherwise vulnerable TimeLock versions (like this one) could be used to unlock future challenges.
I still believe that further post-build obfuscation (using some open-source/free obfuscators) would bring extra security to the table.
I would like to thank the author for this extremely interesting challenge. Hopefully this report will help in making TimeLock secure and unbreakable, and, of course, I’ll be eagerly waiting for future challenges.