The 1753CTF challenge was called Unbreakable. It gave us a ciphertext and the C# code that had produced it. The code XORed the flag with a buffer from System.Random, apparently trying to build a one-time pad.
The name was generous. The important part looked like this:
var seed = new DateTimeOffset(DateTime.Today).ToUnixTimeSeconds();
var random = new Random((int)seed);
var randomBuffer = new byte[flag.Length];
random.NextBytes(randomBuffer);DateTime.Today is local midnight. That means every run on the same day used the same seed and therefore the same byte sequence. There were not billions of plausible secrets to search. There was roughly one candidate per day.
The XOR was not the bug. A genuine one-time pad uses a uniformly random, secret key as long as the message, and never reuses it. This code used the output of a predictable general-purpose PRNG. It looked similar on screen, but it had none of the property that makes a one-time pad unbreakable.
Rebuilding the buffer#
System.Random is deterministic: same seed, same implementation, same sequence. Since I knew the challenge had been prepared near the event, I generated the buffer for each plausible date, XORed it with the ciphertext, and checked whether the result had the known flag format.
There is no reason to brute-force every second. The seed only changes when DateTime.Today changes:
using System.Text;
const string encryptedHex =
"22ECCDB90936D5C2454A65A5BB4C120FB1C8567381C6DB368EB57D4C6BE8B6D8C860E5C6FAC1F48BF2291A5C9EA3C354715857E7";
var ciphertext = Convert.FromHexString(encryptedHex);
var latestLikelyDate = new DateTime(2024, 7, 1);
for (var daysBack = 0; daysBack < 366; daysBack++)
{
var candidateDate = latestLikelyDate.AddDays(-daysBack);
var seed = new DateTimeOffset(candidateDate).ToUnixTimeSeconds();
var random = new Random(unchecked((int)seed));
var randomBuffer = new byte[ciphertext.Length];
random.NextBytes(randomBuffer);
var plaintextBuffer = new byte[ciphertext.Length];
for (var i = 0; i < ciphertext.Length; i++)
plaintextBuffer[i] = (byte)(ciphertext[i] ^ randomBuffer[i]);
var plaintext = Encoding.ASCII.GetString(plaintextBuffer);
if (plaintext.StartsWith("1753c{") && plaintext.EndsWith("}"))
{
Console.WriteLine($"{candidateDate:yyyy-MM-dd}: {plaintext}");
break;
}
}My matching seed was 19 days behind the date I started from. The result:
1753c{you_will_never_guess_the_flag_coz_i_am_xorrro}One small implementation detail: new DateTimeOffset(candidateDate) applies the machine’s local UTC offset. If the challenge was generated in another time zone, search the plausible offsets as well. Also use the same .NET generation as the challenge if a seeded sequence does not match; a PRNG’s output is an implementation detail, not a portable file format.
PRNG does not mean broken#
An ordinary pseudo-random number generator is not useless or defective. System.Random is perfectly reasonable for simulations, games, shuffling a playlist, randomized tests, and other cases where nobody benefits from predicting the next value.
It is wrong for secrets.
A cryptographically secure pseudo-random number generator is usually deterministic too. The difference is in its design and its state:
- It is seeded and periodically reseeded from high-quality entropy maintained by the operating system.
- Observing output should not let you reconstruct its internal state or predict later output.
- Learning the current state should not casually reveal all earlier output.
- The seed space is large enough that guessing it is not a realistic search strategy.
The operating system gathers unpredictability from platform-specific sources and maintains a random pool or generator for applications. You normally ask the OS for bytes; you do not invent a seed from the clock, process ID, username, mouse position, or some home-made mixture of those things.
In modern C#, fill a buffer with RandomNumberGenerator:
using System.Security.Cryptography;
var bytes = new byte[32];
RandomNumberGenerator.Fill(bytes);In a browser, use Web Crypto rather than Math.random():
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);For actual encryption, do not build your own XOR scheme around either function. Use an authenticated-encryption construction such as AES-GCM or ChaCha20-Poly1305 through a maintained cryptographic library. Random bytes solve the randomness problem; they do not automatically give you a safe protocol, key management, nonces, or integrity.
The lesson I kept#
“Random-looking” and “unpredictable to an attacker” are different requirements.
If the value protects a token, password, key, reset link, nonce, lottery result, or anything else someone has a reason to predict, start with the platform’s cryptographic API. Never downgrade its entropy by replacing the seed with something convenient.
And if you find a timestamp feeding a normal PRNG in a CTF, do not stare at the ciphertext for too long. Search the clock.

