evil-calculator write up

Ans: AIS3{fake_flag}

進來網站後,會看到一個像計算機的東西。

image

這裡先隨意輸入 1+1,看他會做什麼事。

image
image

可以發現在我們做計算時,會送出一個 POST 請求,並且檔案是以 JSON 格式傳送,這裡我們攔截請求,先去看看他後端怎麼運作。

image

index.html 完整原始碼
<script>
  let expressionScreen = document.getElementById('expression');

  function appendToExpression(char) {
    expressionScreen.value = expressionScreen.value === '0' ? char : expressionScreen.value + char;
  }

  function clearExpression() {
    expressionScreen.value = '0';
  }

  function calculate() {
    const expression = expressionScreen.value;
    fetch('/calculate', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({expression: expression}),
    })
    .then(response => response.json())
    .then(data => {
      expressionScreen.value = data.result;
    })
    .catch((error) => {
      console.error('Error:', error);
      expressionScreen.value = 'Error';
    });
  }
</script>

在前端的程式碼中,前半段是一般計算機計算跟字元顯示的設定,但後半段傳送 POST 請求中,有一個問題。

const expression = expressionScreen.value;

fetch('/calculate', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({expression: expression}),
})

這裡會把我們的輸入轉成 json 傳到後端做處理,但他這裡並沒有做任何的資料檢查或過濾,意味著我們輸什麼,他就送甚麼進去。

app.py 完整程式碼
from flask import Flask, request, jsonify, render_template

app = Flask(__name__)

@app.route('/calculate', methods=['POST'])
def calculate():
    data = request.json
    expression = data['expression'].replace(" ","").replace("_","")
    try:
        result = eval(expression)
    except Exception as e:
        result = str(e)
    return jsonify(result=str(result))

@app.route('/')
def index():
    return render_template('index.html')

if __name__ == '__main__':
    app.run("0.0.0.0",5001)

這裡有一段程式碼是針對我們的輸入做處理,但沒有很嚴謹。

@app.route('/calculate', methods=['POST'])
def calculate():
    data = request.json
    expression = data['expression'].replace(" ","").replace("_","")
    try:
        result = eval(expression)
    except Exception as e:
        result = str(e)
    return jsonify(result=str(result))

1. 先接收使用者的輸入

而使用者可以控制 data['expression'],這是用戶完全可控的輸入

data = request.json
expression = data['expression']

2. 接著對輸入做過濾

這只把空格和 _ 拿掉,但沒處理其他危險字符,例如:

只要 payload 沒依賴 _,都能執行成功。

expression = data['expression'].replace(" ","").replace("_","")

3. 再來是最重要的部分:eval()

這會把輸入的字串當成原生 Python 程式碼直接執行,例如:

{"expression":"open('/flag.txt').read()"}

執行起來就是:

eval("open('/flag.txt').read()")
result = eval(expression)

4. 然後是例外處理

這裡只會把錯誤訊息包成字串,回傳給前端,不是保護,只是「讓伺服器不要炸掉」。

except Exception as e:
    result = str(e)

5. 最後回傳結果

return jsonify(result=str(result))

他把 eval() 的結果送回給前端,這也代表可以用他來回傳敏感資料,例如:

{"expression":"open('/etc/passwd').read()"}

攻擊

程式中的 /calculate 路由接收 JSON 格式的 POST 請求,然後從中提取 expression 參數。該值僅進行了 .replace(" ","").replace("_","") 的處理,接著就直接被傳入 eval()。由於 eval() 能執行任意 Python 程式碼,因此我可以構造惡意輸入,例如:

{"expression":"open('/flag.txt').read()"}

image

雖然是拿到 fake_flag,後來有嘗試 ls,這裡因為他鎖 _,我沒辦法用 import,所以直接用 chr 一個一個拼出來,但並沒有給其他有用的檔。

image