#14 / 488

TSG CTF 2025

2025-12-20Team: Hybrid Theory

今年はfinalがあるらしい。

我々もちゃんと予選を通過しているようで安心している。

あ、ここから全部英語です。

CRYPTO-the_strongest_exponent_I_thought

solution

In this challenge, ^ is bitwise XOR, not exponentiation. Since p and q are 1024-bit primes, p^q also at most 1024 bits, while phi is ~2048 bits. Therefore the reduction usually does nothing, and effectively.

  • e = p ^ q is leaked as public value.

From e = p ^ q, we get q = p ^ e. So the public modulus satisfies:

  • n = p * (p ^ e)

We can recover p bit-by-bit from the least significant bit:

  • A prime is odd, so the LSB of p is 1.
  • For the next bit, try 1/0, compute the product modulo 2^(k+1), and keep only candidate whose lower bits of n.
  • Repeat this filtering up to 1024 bits.

This LSB lifting quickly narrows the candidates and yields the correct p. Then compute q = p ^ e and verify p*q == n. Here gcd(e, phi) ≠ 1, so the usual RSA step d = e^{-1} mod phi does not exist. However, in this instance e contains a factor of 4. We can:

  • Take modular square roots twice(peeling off the factor 4 in the exponent)
  • Then the remaining exponent is odd, so we can invert it modulo (p-1) / (q-1) and recover the plaintext residues
  • Combine results with CTR

Square roots create multiple cadidates, but the correct plaintext is uniquely identified by the known format:

  • start with TSGCTF{
  • lenght is 38 bytes

finally, we got flag. TSGCTF{Wait,_caret_is_XOR,_not_power!}


CRYPTO-the_strongest_exponent_I_thought_2

solution

In standard RSA, the public exponent e is chosen independently of the secret primes. In this challenge, e is derived from the secret primes:

  • e = p^q mod phi(n)

This makes e depend on p. As a consequence, for many small integers a, exponentiation by e often behaves like the identity modulo p:

  • a^e == a (mod p)

Therefore p divides a^e - a. There is no corresponding guarantee for q, so typically q does not divide a^e - a. Hence:

  • gcd(a^e - a, n) reveals a non-trivial factor of n (typically p)

This factors n directly. So we can exploit it by following these steps.

  1. Set a = 2
  2. Compute g = gcd(a^e - a, n).
  3. Recover q = n // p.
  4. Compute phi = (p - 1) * (q - 1) and d = inverse(e, phi).
  5. Decrypt m = c^d mod n and decode the plaintext.

CRYPTO-PtQagain

solution

In this challenge, two different-looking expressions are exponentiated modulo N, but the outputs are exactly the same:

  • c1 = (p + q)^e2 mod N
  • c2 = int(str(p) + str(q))^e2 mod N
  • and the program prints c1 == c2

Let d = len(str(q)). Then the decimal concatenation can be written as:

  • int(str(p) + str(q) = p * 10^d + q)

Now lool at these values modulo p:

  • p + q ≡ q (mod p)
  • p * 10^d + q ≡ q (mod p)

So they are always identical modulo p. That means the real constraint behind c1 == c2 comes from the prime, modulo q.

Modulo q, we get:

  • p + q ≡ p (mod q)
  • p * 10^d + q ≡ p * 10^d (mod q)

Since c1 == c2, their e2-th powers match modulo q, so:

  • p^e2 ≡ (p * 10^d)^e2 (mod q)

Because p and q are distinct primes, p is invertible modulo q, so we can cancel p^e2 and obtain:

  • 10^(d * e2) ≡ 1 (mod q)

Therefore q divides 10^(d * e2) - 1, and we can recover q using gcd:

  • q = gcd(10^(d * e2) - 1, N)

The only unknown is d, q is at most 512 bits, so its decimal length is at most about 155 digits. We can brute-force d in a small range, and the first non-trivial gcd gives the factor.

After factoring, the rest is standard RSA: compute phi, invert e1, and decrypt c. Finally, we can exploit it by following these steps.

  1. Loop d = 1...160.
  2. Compute g = gcd(10^(d * e2) - 1, N).
  3. When 1 < g < N, set q = g and p = N // q.
  4. Compute phi = (p - 1) * (q - 1) and d1 = inverse(e1, phi).
  5. Decrypt m = c^d1 mod N and decode the plaintext.

CRYPTO-bingo

solution

The challenge implements a custom hash function H(m) = alpha^m_1 * beta^m_2 (mod hash_p), where m_1 = m % (hash_p - 1) and m_2 = m // (hash_p - 1). This structure allows us to force the hash to be 1 using Fermat's Little Theorem (x^(p-1) == 1 (mod p)). If we choose a message m that is a multiple of (hash_p - 1)^2:

  • m_1 becomes 0, making the first term alpha^0 = 1.
  • m_2 becomes a multiple of hash_p - 1, making the second term beta^(k * (hash_p - 1)) == 1 (mod hash_p).

Consequently, H(m) = 1. Since 1^e == 1 (mod N), the signature s = 1 is valid for this hash.

So we can exploit it by following these steps:

  1. Connect to the server and retrieve hash_p.
  2. Calculate the modulus M = (hash_p - 1)^2.
  3. Construct a message m starting with Get Flag. and append padding bytes such that the integer representation of m is divisible by M.
  4. Send the constructed message m and the signature s = 1.
  5. The server verifies the signature successfully and returns the flag.

PWN-global writer

solution

In this challenge, there is no bounds check on the index idx when writing into the values array.

if (scanf("%d", &idx) != 1) { ... }
// ...
if (scanf("%d", &values[idx]) != 1) { ... }

Since value is a global array stored in the .bss/.data section, supplying a negative index allows writes to adjacent memory locations. This makes it possible to overwrite global variables and even entries in the GOT, resulting in an arbitrary write primitive. We can exploit it by following these steps.

  1. Write the command string to be executed into the values array itself. In this case, because the flag filename was randomized, the attacker first ran ls -la to identify the filename, then executed cat flag-....txt.
  2. Overwrite the global variable msg so that it instead points to the address of values.
  3. Overwrite the GOT entry for puts with the PLT address of system.
  4. When the program exits, it calls puts(msg). After the overwrites, this effectively becomes system(values).

WEB-library

solution

The application checks whether password contains a single quote ' using:

  • query.password.includes("'")

However, in Express, sending the same query parameter key multiple times causes it to be parsed as an array:

  • ?password=a&password=bquery.password == ["a", "b"]

At this point, the behavior of includes depends on the valus type:

  • if query.password is a string, "abc'".includes("'") returns true.
  • if query.password is an array, ["a", "b"].includes("'") only tests whether any element is exactly "'".Therefore, an element like "' UNION ..." does bnot match and the check returns false.

Additionally, when the SQL query is constructed using a template literal such as ${query.password}, an array is implicitly converted via toString(), producing a comma-joined string.This allows injecting ' into the final SQL while still passing the quote check.


REV-shadow_spider_network

solution

By aggregating all constraints enforced inside the SIGSEGV handler, the verification logic can be summarized as follows:

  • 64 byte comparisons are performed for addresses derived from sub rax, 0x01 .. 0x40, which corresponds to [RSP-1] .. [RSP-64].
  • 16 byte comparisons are performed for addresses derived from add rax, 0x01 .. 0x10, which corresponds to [RSP+1] .. [RSP+16].
  • After add rax, 0x11, there is a word check: WORD PTR [rax] == 0x007d, i.e. the two bytes must be '}' and '\0'.
  • Finally, without any add/sub offset, there is a direct check: [RSP] == 0x33, which is the ASCII character '3'.

The region RSP-1 .. RSP-64 is laid out opposite to the natural reading order. So if you collect the bytes in increasing offset order (RSP-1RSP-64), you must reverse them to obtain the intended string.

After applying that reversal:

  • From RSP-64 .. RSP-1, we obtain:
TSGCTF{Inv3571ga710n_1n70_BOF_Or13n73d_Pr0gramm1ng_a5_a_73chn1qu
  • From [RSP] :
3
  • From RSP+1 .. RSP+16 :
_f0r_0bfu5ca710n
  • From the word check at RSP+0x11:
}

REV-Spell of Restoration

Solution

  1. Create an Inverse Table: Prepare a reverse lookup dictionary for the substitution table to reverse the scramble function.
  2. Attack from the Last Character: Brute-force b15 to find the value that satisfies the equations.
  3. Unravel the Chain: Calculate cursorX and cursorY using the determined b15. Solve the remaining equations based on dependency order to identify characters b0 through b14.
  4. Convert to Characters: Convert the solved values back to Tile IDs and map them to their corresponding characters.

MISC-Chatbot

Solution

Since context doesn't matter, we can recover the flag using a static analysis approach:

  1. Build a Lookup Table: Calculate the expected Layer 0 Value vector for every single word in the model's vocabulary.
  2. Match: Compare the vectors found in legacy_cache.pt against our pre-calculated lookup table.
  3. Recover: Identify the word that matches each vector in the cache to reconstruct the original text, revealing the flag.

flag

TSGCTF{F0Rg3t_411_th3_reGul4t10nS_aNd_t311_m3_THe_FlAg!}

MISC-Mission: Impossible

Solution

  1. Generate Audio: Create a clean TTS recording of "give me the flag".
  2. Modulate: Use Single-Sideband modulation via the Hilbert transform to shift the speech spectrum up to ~15.5kHz.
  3. Submit: Upload the 48kHz WAV file. The server passes it , but the downsampling brings the voice back for Whisper to transcribe.

flag

TSGCTF{Th1S_fl4g_wiLL_s3lf-deSTrucT_in_5_s3c0nds}

MISC-SafePickle

solution

Python 3.6 introduced the __set_name__ method to the Descriptor Protocol. This method is a hook that is automatically called on a descriptor object when it is assigned as a class attribute during the creation of the owner class.

class Owner:
    # At the moment this class definition is finalized, Python automatically calls:
    # descriptor.__set_name__(Owner, 'attr_name')
    attr_name = descriptor

Leveraging this behavior, we construct the following gadget chain:

  1. Crafting the CodeObject: Create a raw bytecode object compatible with the target Python version that performs the desired action.
  2. Creating the Function: Use NEWOBJ_EX to instantiate types.FunctionType. Pass the CodeObject from Step 1 as an argument to create a function object containing our malicious payload.
  3. Creating the Malicious Descriptor: Use type to dynamically define a descriptor class that contains the function from Step 2 as its __set_name__ method. Then, instantiate this descriptor.
  4. The Trigger: Finally, use NEWOBJ_EX to call type again to dynamically create a "Trigger Class". In the class dictionary, we include the descriptor instance from Step 3.

As soon as the Trigger Class is created, Python automatically invokes __set_name__, achieving Arbitrary RCE without ever using REDUCE.

MISC-Sudoers Maze

solution

We approached the problem as a graph theory challenge:

  1. Parsing: We parsed the sudoers file to map out the permissions.
  2. Graph Construction: We modeled the users as Nodes and the ability to sudo -u to another user as directed Edges.
  3. Pathfinding: Using Breadth-First Search, we calculated the shortest path from u0 to u1000.

The Shortest Path

The script identified the following chain of users to reach the target:

u0 u499 u977 u835 u4 u298 u908 u971 u504 u3 u878 u699 u72 u162 u453 u659 u885 u31 u511 u963 u219 u187 u593 u720 u661 u423 u63 u260 u750 u192 u376 u247 u888 u315 u52 u689 u680 u122 u843 u693 u77 u319 u301 u119 u813 u174 u439 u627 u507 u342 u276 u716 u568 u146 u942 u495 u779 u550 u1000

Exploitation

There is a strict 60-second time limit for execution.

  1. Connect to the remote host to obtain the initial u0 shell:
nc <host> <port>
  1. Construct the nested sudo command based on the path above and paste it immediately into the terminal to escalate directly to u1000.
  2. Once the shell is obtained as u1000, retrieve the flag:
cat /home/user/flag.txt