m0unt41n - m
Walkthrough and solution for the m challenge, recovering the hidden modulus by abusing multiplicative relations in a modular exponentiation oracle.
Overview
This challenge provides a small Python service.
The service asks for an integer m and then returns:
1
(m ^ randval) % FLAG
The flag is 44 digits long and is used directly inside the calculation.
To run the challenge locally, I first created a Python virtual environment and installed the required dependency:
1
2
3
4
5
sudo apt install python3-venv python3-pip
python3 -m venv venv
source venv/bin/activate
pip install pycryptodome
python3 chal.py
After starting the challenge, the program prints:
1
2
3
You provide an integer 'm', and we return the result of (m ^ randval) % FLAG.
The FLAG is 44-digits long. Good luck!
Type 'exit' to quit anytime.
Some manual tests looked like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Enter an integer m: 1
Result: (m ^ randval) % FLAG = 1
Enter an integer m: 2
Result: (m ^ randval) % FLAG = 1248998864894437397402509868276212092985339
Enter an integer m: 3
Result: (m ^ randval) % FLAG = 1239849368078290557990493755785720078720794
Enter an integer m: 4
Result: (m ^ randval) % FLAG = 8714198503583603080625689869919297549352586
Enter an integer m: -1
Result: (m ^ randval) % FLAG = 1
Enter an integer m: -2
Result: (m ^ randval) % FLAG = 1248998864894437397402509868276212092985339
Enter an integer m: 0
Result: (m ^ randval) % FLAG = 0
At first, this looks like an XOR challenge because of the ^ symbol in the output.
However, the source code shows that the service is not using XOR.
Step 1: Inspecting the Source Code
The relevant part of the challenge source is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from os import environ
from secrets import randbelow
from Crypto.Util.number import bytes_to_long
FLAG = bytes_to_long(environ.get("FLAG", "stairctf{redacted}").encode())
def main():
randval = randbelow(FLAG)
print(
"You provide an integer 'm', and we return the result of (m ^ randval) % FLAG."
)
print(f"The FLAG is {len(str(FLAG))}-digits long. Good luck!")
print("Type 'exit' to quit anytime.\n")
while True:
try:
user_input = input("Enter an integer m: ")
if user_input.lower() == "exit":
print("Goodbye!")
break
m = int(user_input)
result = pow(m, randval, FLAG)
print(f"Result: (m ^ randval) % FLAG = {result}\n")
except ValueError:
print(
"Invalid input. Please enter a valid integer or type 'exit' to quit.\n"
)
if __name__ == "__main__":
main()
The important line is:
1
result = pow(m, randval, FLAG)
In Python, pow(m, randval, FLAG) calculates:
1
m^randval mod FLAG
So the printed expression is misleading.
The actual oracle is:
1
O(m) = m^randval mod FLAG
Where:
1
2
randval = random value generated once when the process starts
FLAG = hidden modulus
The goal is to recover the hidden FLAG.
Step 2: Understanding the Weakness
The issue is that the flag is used as the modulus.
The service lets us query many values of:
1
m^randval mod FLAG
Because the same randval is reused during one connection, the oracle has a useful multiplicative property.
For two chosen values a and b:
1
2
O(a) = a^randval mod FLAG
O(b) = b^randval mod FLAG
Multiplying both oracle outputs gives:
1
O(a) * O(b) ≡ a^randval * b^randval mod FLAG
This can be rewritten as:
1
O(a) * O(b) ≡ (a*b)^randval mod FLAG
But (a*b)^randval mod FLAG is exactly the oracle output for a*b:
1
O(a*b) = (a*b)^randval mod FLAG
Therefore:
1
O(a) * O(b) ≡ O(a*b) mod FLAG
So the difference must be divisible by FLAG:
1
O(a) * O(b) - O(a*b) = k * FLAG
This means every pair gives us a multiple of the hidden flag integer.
With enough pairs, we can recover the common factor by calculating the greatest common divisor:
1
2
3
4
5
6
FLAG = gcd(
O(a1) * O(b1) - O(a1*b1),
O(a2) * O(b2) - O(a2*b2),
O(a3) * O(b3) - O(a3*b3),
...
)
This is the core of the attack.
Step 3: Testing the Relation Locally
From the local test output, we know for example:
1
2
O(2) = 1248998864894437397402509868276212092985339
O(4) = 8714198503583603080625689869919297549352586
Using the pair (2, 2) gives:
1
O(2) * O(2) - O(4)
This value must be divisible by the hidden FLAG.
One pair is usually not enough, because the result can still contain an additional unknown multiplier. Therefore, I collected several such values and calculated the gcd.
Useful pairs are:
1
2
3
4
5
6
7
8
(2, 3)
(2, 5)
(3, 5)
(2, 7)
(5, 7)
(2, 11)
(3, 11)
(7, 11)
Each pair gives one multiple of the hidden modulus.
After enough pairs, the gcd collapses to the actual flag integer.
Step 4: Connecting to the Remote Service
The remote service is reached with:
1
ncat --ssl d72274ab-712c-4c70-a4aa-5fe732ba2ac4.library.m0unt41n.ch 31337
The --ssl part is important as I hade some issues with my script at first.
A normal Python socket does not work because the service expects a TLS connection.
This would fail:
1
socket.create_connection((HOST, PORT))
The socket has to be wrapped with SSL:
1
2
context = ssl._create_unverified_context()
s = context.wrap_socket(raw, server_hostname=HOST)
This behaves like ncat --ssl.
Step 5: Exploit Script
The final exploit connects to the remote service over SSL, queries several values, calculates the modular differences, and then takes the gcd.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#!/usr/bin/env python3
import socket
import ssl
import re
from math import gcd
HOST = "d72274ab-712c-4c70-a4aa-5fe732ba2ac4.library.m0unt41n.ch"
PORT = 31337
def long_to_bytes(n):
return n.to_bytes((n.bit_length() + 7) // 8, "big")
def recv_until(sock, marker):
data = b""
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
raise EOFError(data.decode(errors="replace"))
data += chunk
return data
def query(sock, m):
sock.sendall(str(m).encode() + b"\n")
data = recv_until(sock, b"Enter an integer m:")
match = re.search(rb"Result:.*?=\s*(\d+)", data)
if not match:
print(data.decode(errors="replace"))
raise ValueError("Could not parse result")
return int(match.group(1))
def connect_ssl():
raw = socket.create_connection((HOST, PORT), timeout=10)
# Similar to ncat --ssl.
# Certificate verification is disabled because this is a CTF service.
context = ssl._create_unverified_context()
return context.wrap_socket(raw, server_hostname=HOST)
with connect_ssl() as s:
banner = recv_until(s, b"Enter an integer m:")
print(banner.decode(errors="replace"))
m = re.search(rb"FLAG is (\d+)-digits", banner)
target_digits = int(m.group(1)) if m else 44
cache = {}
def O(x):
if x not in cache:
cache[x] = query(s, x)
print(f"O({x}) = {cache[x]}")
return cache[x]
pairs = [
(2, 3),
(2, 5),
(3, 5),
(2, 7),
(5, 7),
(2, 11),
(3, 11),
(7, 11),
(2, 13),
(5, 13),
(11, 13),
(2, 17),
(3, 17),
(5, 17),
(13, 17),
(2, 19),
(3, 19),
]
g = 0
for a, b in pairs:
diff = abs(O(a) * O(b) - O(a * b))
if diff != 0:
g = gcd(g, diff)
print(f"[+] pair ({a}, {b}) -> gcd = {g}")
print(f"[+] gcd digits = {len(str(g))}")
if len(str(g)) == target_digits:
candidate = long_to_bytes(g)
print("[+] Candidate integer:")
print(g)
print("[+] Candidate bytes:")
print(candidate)
try:
print("[+] Candidate decoded:")
print(candidate.decode())
except UnicodeDecodeError:
pass
break
Step 6: Running the Exploit
Running the script against the remote service:
1
python3 exploit.py
The script prints the queried oracle values and updates the gcd after every pair.
The recovered integer can be converted back to bytes because the challenge originally converted the flag with:
1
FLAG = bytes_to_long(environ.get("FLAG", "stairctf{redacted}").encode())
So reversing it with long_to_bytes() gives the flag string.
Why This Works
The service leaks arithmetic relations modulo the secret value.
The secret flag is used as the modulus, and the user can freely query modular exponentiation results.
Because the exponent randval stays the same for the whole connection, the following relation always holds:
1
O(a) * O(b) ≡ O(a*b) mod FLAG
Therefore:
1
FLAG | (O(a) * O(b) - O(a*b))
Every chosen pair leaks a multiple of the flag. Taking the gcd of several leaked multiples removes the random multipliers and leaves the hidden modulus.
This is a classic hidden-modulus recovery issue.
Vulnerability Summary
The vulnerability is caused by using the secret flag as a modulus in an exposed arithmetic oracle.
The challenge tries to hide the flag behind modular exponentiation:
1
result = pow(m, randval, FLAG)
However, because the attacker can choose m and the same exponent is reused, the oracle leaks enough modular structure to recover FLAG.
The misleading output string:
1
(m ^ randval) % FLAG
also makes the challenge look like XOR at first, but the actual implementation is modular exponentiation.
A secure implementation should never use a secret value directly as a modulus in an attacker-controlled oracle. If modular arithmetic is required, the modulus should be public and the secret should not be recoverable through algebraic relations. Reusing the same hidden exponent also makes the oracle much easier to exploit.

