本文来源自平安银河实验室
作者:荣生
0x01 概述
在进行Java代码审计的过程中经常会涉及到Java工程项目的环境部署及代码调试,本文主要针对Java项目常见的SpringBoot+Thymeleaf框架进行Http网络请求分析。
0x02 SpringBoot框架
先说说Spring、SpringMVC、Springboot三者之间的关系,Spring、Spring Boot 和 Spring MVC 都是与 Java 应用程序开发相关的框架。
* Spring 是一个开源的轻量级应用程序框架,它可以帮助我们构建企业级应用程序,并提供了许多用于处理各种企业级应用程序开发任务的功能,例如依赖注入、面向切面编程、事务管理等。
* Spring MVC 是 Spring 框架中的一部分,它是一个基于模型-视图-控制器(MVC)模式的 Web 框架。它提供了一个模型-视图-控制器(MVC)架构,用于构建 Web 应用程序,并且可以与许多视图技术集成。
* Spring Boot 是一个构建在 Spring 框架之上的框架,它通过自动配置和约定优于配置的方式简化了 Spring 应用程序的开发和部署。Spring Boot 可以帮助我们快速创建独立的、生产级别的 Spring 应用程序。
总的来说,Spring 是一个全面的应用程序框架,而 Spring MVC 是其中的一个 Web 框架,Spring Boot 则是一个构建在 Spring 基础上的开箱即用的框架。因此他们的关系大概就是这样:spring mvc < spring < springboot。
MVC是Model(模型) + View(视图)+ Controller(控制层)的简称,其中Model层主要负责数据处理、View负责视图的渲染和加载、Controller负责处理用户请求及逻辑控制等。下图是用户发起网络请求后MVC框架层的流程图:
0x03 Springboot http
3.1 Springboot http请求处理流程
Spring Boot 基于 Spring 框架,处理 HTTP 请求的流程也与 Spring 框架类似。下面是 Spring Boot 处理 HTTP 请求的大致流程:
1. 客户端发起 HTTP 请求,请求到达 Web 服务器。
2. Web 服务器将请求发送到 Spring Boot 应用程序中的 DispatcherServlet。
3. DispatcherServlet 通过 HandlerMapping 找到处理该请求的 Controller。
4. Controller 处理请求,生成 Model 数据,然后返回逻辑视图名。
5. DispatcherServlet 根据视图名查找 View,然后将 Model 数据传递给 View。
6. View 渲染 Model 数据,生成 HTML 响应。
7. DispatcherServlet 将 HTML 响应发送给客户端。
在上述过程中,Spring Boot 会涉及到许多组件,例如 DispatcherServlet、HandlerMapping、Controller、ViewResolver 等。此外,Spring Boot 还提供了许多注解和工具类,以简化开发人员的工作。例如,使用 @RestController 注解可以将 Controller 的所有方法都默认映射为 RESTful Web 服务,使用 RestTemplate 工具类可以方便地发送 HTTP 请求。
3.2 Springboot Http请求流程图
3.3 具体实现
(1)FrameworkServlet
FrameworkServlet 继承自 HttpServletBean,而 HttpServletBean 继承自 HttpServlet,网络请求经过filter后,开始处理是在HttpServlet的service 方法。下面是它们之间的UML类图:
我们先来看看 HttpServlet#service 方法:
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
HttpServletRequest request;
HttpServletResponse response;
try {
request = (HttpServletRequest)req;
response = (HttpServletResponse)res;
} catch (ClassCastException var6) {
throw new ServletException(lStrings.getString("http.non_http"));
}
this.service(request, response);
}
子类FrameworkServlet重写了service请求:
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
HttpMethod httpMethod = HttpMethod.resolve(request.getMethod());
if (httpMethod != HttpMethod.PATCH && httpMethod != null) {
super.service(request, response);
} else {
this.processRequest(request, response);
}
}
该方法中,首先获取到当前请求方法,如果是patch请求直接走processRequest,其他类型的请求全部通过 super.service 进行处理。
在FrameworkServlet父类HttpServlet 中的service 方法根据请求的method类型分别调用不同的处理,但在HttpServlet中doGet、doPost等并没有具体实现,在子类FrameworkServlet 中重写了各种请求对应的方法,如 doDelete、doGet、doOptions、doPost、doPut、doTrace 等。
我们先来看看 doDelete、doGet、doPost 以及 doPut 四个方法:
@Override
protected final void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
processRequest(request, response);
}
@Override
protected final void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
processRequest(request, response);
}
可以看到,请求最后还是交给 processRequest 去处理了,在 processRequest 方法中则会进一步调用到 doService,对不同类型的请求分类处理。
(1.1)processRequest
processRequest 其实主要做了两件事,第一件事就是对 LocaleContext 和 RequestAttributes 的处理,第二件事就是发布事件publishRequestHandledEvent。
protected final void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
long startTime = System.currentTimeMillis();
Throwable failureCause = null;
LocaleContext previousLocaleContext = LocaleContextHolder.getLocaleContext();
LocaleContext localeContext = this.buildLocaleContext(request);
RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes requestAttributes = this.buildRequestAttributes(request, response, previousAttributes);
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
asyncManager.registerCallableInterceptor(FrameworkServlet.class.getName(), new RequestBindingInterceptor());
this.initContextHolders(request, localeContext, requestAttributes);
try {
this.doService(request, response);
} catch (IOException | ServletException var16) {
failureCause = var16;
throw var16;
} catch (Throwable var17) {
failureCause = var17;
throw new NestedServletException("Request processing failed", var17);
} finally {
this.resetContextHolders(request, previousLocaleContext, previousAttributes);
if (requestAttributes != null) {
requestAttributes.requestCompleted();
}
this.logResult(request, response, (Throwable)failureCause, asyncManager);
this.publishRequestHandledEvent(request, response, startTime, (Throwable)failureCause);
}
}
最后就是 processRequest 方法中的事件发布了。
在 finally 代码块中会调用 publishRequestHandledEvent 方法发送一个 ServletRequestHandledEvent 类型的事件,具体发送代码如下:
private void publishRequestHandledEvent(HttpServletRequest request, HttpServletResponse response, long startTime, @Nullable Throwable failureCause) {
if (this.publishEvents && this.webApplicationContext != null) {
long processingTime = System.currentTimeMillis() - startTime;
this.webApplicationContext.publishEvent(new ServletRequestHandledEvent(this, request.getRequestURI(), request.getRemoteAddr(), request.getMethod(), this.getServletConfig().getServletName(), WebUtils.getSessionId(request), this.getUsernameForRequest(request), processingTime, failureCause, response.getStatus()));
}
}
可以看到,事件的发送需要 publishEvents 为 true,而该变量默认就是 true。如果需要修改该变量的值,可以在 web.xml 中配置 DispatcherServlet 时,通过 init-param 节点顺便配置一下该变量的值。正常情况下,这个事件总是会被发送出去。
(2)DispatcherServlet
(2.1)doService
doService在FrameworkServlet是一个抽象方法,具体实现在子类DispatcherServlet中
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
this.logRequest(request);
Map<String, Object> attributesSnapshot = null;
if (WebUtils.isIncludeRequest(request)) {
attributesSnapshot = new HashMap();
Enumeration<?> attrNames = request.getAttributeNames();
label116:
while(true) {
String attrName;
do {
if (!attrNames.hasMoreElements()) {
break label116;
}
attrName = (String)attrNames.nextElement();
} while(!this.cleanupAfterInclude && !attrName.startsWith("org.springframework.web.servlet"));
attributesSnapshot.put(attrName, request.getAttribute(attrName));
}
}
request.setAttribute(WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.getWebApplicationContext());
request.setAttribute(LOCALE_RESOLVER_ATTRIBUTE, this.localeResolver);
request.setAttribute(THEME_RESOLVER_ATTRIBUTE, this.themeResolver);
request.setAttribute(THEME_SOURCE_ATTRIBUTE, this.getThemeSource());
if (this.flashMapManager != null) {
FlashMap inputFlashMap = this.flashMapManager.retrieveAndUpdate(request, response);
if (inputFlashMap != null) {
request.setAttribute(INPUT_FLASH_MAP_ATTRIBUTE, Collections.unmodifiableMap(inputFlashMap));
}
request.setAttribute(OUTPUT_FLASH_MAP_ATTRIBUTE, new FlashMap());
request.setAttribute(FLASH_MAP_MANAGER_ATTRIBUTE, this.flashMapManager);
}
RequestPath previousRequestPath = null;
if (this.parseRequestPath) {
previousRequestPath = (RequestPath)request.getAttribute(ServletRequestPathUtils.PATH_ATTRIBUTE);
ServletRequestPathUtils.parseAndCache(request);
}
try {
this.doDispatch(request, response);
} finally {
if (!WebAsyncUtils.getAsyncManager(request).isConcurrentHandlingStarted() && attributesSnapshot != null) {
this.restoreAttributesAfterInclude(request, attributesSnapshot);
}
if (this.parseRequestPath) {
ServletRequestPathUtils.setParsedRequestPath(previousRequestPath, request);
}
}
}
(2.2)doDispatch
其中核心调用就是doDispatch方法:
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
boolean multipartRequestParsed = false;
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
try {
try {
ModelAndView mv = null;
Exception dispatchException = null;
try {
//1.判断是否文件上传
processedRequest = this.checkMultipart(request);
multipartRequestParsed = processedRequest != request;
//2.获取处理Handler
mappedHandler = this.getHandler(processedRequest);
if (mappedHandler == null) {
this.noHandlerFound(processedRequest, response);
return;
}
HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler());
String method = request.getMethod();
//3.判断是否get请求
boolean isGet = HttpMethod.GET.matches(method);
if (isGet || HttpMethod.HEAD.matches(method)) {
long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
//4.如果资源文件没有过期,直接返回
if ((new ServletWebRequest(request, response)).checkNotModified(lastModified) && isGet) {
return;
}
}
//4.拦截器preHandle
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
//5.获取ModelAndView视图
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
this.applyDefaultViewName(processedRequest, mv);
//6.拦截器postHandle
mappedHandler.applyPostHandle(processedRequest, response, mv);
} catch (Exception var20) {
dispatchException = var20;
} catch (Throwable var21) {
dispatchException = new NestedServletException("Handler dispatch failed", var21);
}
//7.渲染视图
this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException);
} catch (Exception var22) {
this.triggerAfterCompletion(processedRequest, response, mappedHandler, var22);
} catch (Throwable var23) {
this.triggerAfterCompletion(processedRequest, response, mappedHandler, new NestedServletException("Handler processing failed", var23));
}
} finally {
if (asyncManager.isConcurrentHandlingStarted()) {
if (mappedHandler != null) {
mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
}
} else if (multipartRequestParsed) {
this.cleanupMultipart(processedRequest);
}
}
}
(2.3)processDispatchResult
处理请求结果
private
void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
HandlerExecutionChain mappedHandler,
ModelAndView mv,
Exception exception) throws Exception {
boolean errorView =
false
;
if
(exception !=
null
) {
if
(exception instanceof ModelAndViewDefiningException) {
this
.logger.debug(
"ModelAndViewDefiningException encountered"
, exception);
mv = ((ModelAndViewDefiningException)exception).getModelAndView();
}
else
{
Object handler = mappedHandler !=
null
? mappedHandler.getHandler() :
null
;
mv =
this
.processHandlerException(request, response, handler, exception);
errorView = mv !=
null
;
}
}
if
(mv !=
null
&& !mv.wasCleared()) {
this
.render(mv, request, response);
if
(errorView) {
WebUtils.clearErrorRequestAttributes(request);
}
}
else
if
(
this
.logger.isTraceEnabled()) {
this
.logger.trace(
"No view rendering, null ModelAndView returned."
);
}
if
(!WebAsyncUtils.getAsyncManager(request).isConcurrentHandlingStarted()) {
if
(mappedHandler !=
null
) {
mappedHandler.triggerAfterCompletion(request, response, (Exception)
null
);
}
}
}
exception 不为空时加载错误视图
调用view.render渲染视图view#render是一个接口类方法,具体有很多的实现类。
HtmlResourceView
StaticView
AbstractView
ThymeleafView
AjaxThymeleafView
(3)View视图层
视图层会根据Http请求响应结果渲染视图,上面实现View#render接口方法的类都可以进行视图渲染。Springboot中会使用Thymeleaf作为视图解析器,在Controller控制器方法中所返回的视图名(无任何前后缀)会被解析,拼接上配置文件中Thymeleaf设置的前后缀,然后跳转到视图页面。其中ThymeleafView组件容易发生模板注入漏洞,已经存在多个CVE漏洞。
在Java工程项目的pom.xml依赖配置文件中会有Springboot和thymeleaf声明:
<
dependencies
>
<
dependency
>
<
groupId
>
org.springframework.boot
</
groupId
>
<
artifactId
>
spring-boot-starter-web
</
artifactId
>
</
dependency
>
<!-- thymeleaf 模版引擎 -->
<
dependency
>
<
groupId
>
org.springframework.boot
</
groupId
>
<
artifactId
>
spring-boot-starter-thymeleaf
</
artifactId
>
</
dependency
>
</
dependencies
>
一般的控制器方法示例如下:
4j
(
"/admin/u"
)
public
class
UserLoginController
extends
BaseController
{
(
"toLogin"
)
public
ModelAndView
toLogin
()
{
ModelAndView mv =
new
ModelAndView();
User user = (User) SecurityUtils.getSubject().getPrincipal();
mv.setViewName(
"admin/login"
);
return
mv;
}
}
在上面的示例中,当用户访问http://xx.xx.xx/admin/u/toLogin时,控制器会返回ViewName为admin/login的 ModelAndView,视图解析器会根据视图名去自动查找src/main/resources/templtes/admin下的视图并加载。
下面分析下ThymeleafView加载视图流程:
public
void
render(
Map
<
String
, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
this
.renderFragment(
this
.markupSelectors, model, request, response);
}
protected
void
renderFragment(
Set
<
String
> markupSelectorsToRender,
Map
<
String
, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
ServletContext servletContext =
this
.getServletContext();
String
viewTemplateName =
this
.getTemplateName();
ISpringTemplateEngine viewTemplateEngine =
this
.getTemplateEngine();
if
(viewTemplateName ==
null
) {
throw
new
IllegalArgumentException(
"Property 'templateName' is required"
);
}
else
if
(
this
.getLocale() ==
null
) {
throw
new
IllegalArgumentException(
"Property 'locale' is required"
);
}
else
if
(viewTemplateEngine ==
null
) {
throw
new
IllegalArgumentException(
"Property 'templateEngine' is required"
);
}
else
{
Map
<
String
,
Object
> mergedModel =
new
HashMap(
30
);
Map
<
String
,
Object
> templateStaticVariables =
this
.getStaticVariables();
if
(templateStaticVariables !=
null
) {
mergedModel.putAll(templateStaticVariables);
}
if
(pathVariablesSelector !=
null
) {
Map
<
String
,
Object
> pathVars = (
Map
)request.getAttribute(pathVariablesSelector);
if
(pathVars !=
null
) {
mergedModel.putAll(pathVars);
}
}
if
(model !=
null
) {
mergedModel.putAll(model);
}
ApplicationContext applicationContext =
this
.getApplicationContext();
RequestContext requestContext =
new
RequestContext(request, response,
this
.getServletContext(), mergedModel);
SpringWebMvcThymeleafRequestContext thymeleafRequestContext =
new
SpringWebMvcThymeleafRequestContext(requestContext, request);
addRequestContextAsVariable(mergedModel,
"springRequestContext"
, requestContext);
addRequestContextAsVariable(mergedModel,
"springMacroRequestContext"
, requestContext);
mergedModel.put(
"thymeleafRequestContext"
, thymeleafRequestContext);
ConversionService conversionService = (ConversionService)request.getAttribute(ConversionService.class.getName());
ThymeleafEvaluationContext evaluationContext =
new
ThymeleafEvaluationContext(applicationContext, conversionService);
mergedModel.put(
"thymeleaf::EvaluationContext"
, evaluationContext);
IEngineConfiguration configuration = viewTemplateEngine.getConfiguration();
WebExpressionContext context =
new
WebExpressionContext(configuration, request, response, servletContext,
this
.getLocale(), mergedModel);
String
templateName;
Set
markupSelectors;
if
(!viewTemplateName.contains(
"::"
)) {
templateName = viewTemplateName;
markupSelectors =
null
;
}
else
{
SpringRequestUtils.checkViewNameNotInRequest(viewTemplateName, request);
IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration);
FragmentExpression fragmentExpression;
try
{
fragmentExpression = (FragmentExpression)parser.parseExpression(context,
"~{"
+ viewTemplateName +
"}"
);
}
catch
(TemplateProcessingException var25) {
throw
new
IllegalArgumentException(
"Invalid template name specification: '"
+ viewTemplateName +
"'"
);
}
FragmentExpression.ExecutedFragmentExpression fragment = FragmentExpression.createExecutedFragmentExpression(context, fragmentExpression);
templateName = FragmentExpression.resolveTemplateName(fragment);
markupSelectors = FragmentExpression.resolveFragments(fragment);
Map
<
String
,
Object
> nameFragmentParameters = fragment.getFragmentParameters();
if
(nameFragmentParameters !=
null
) {
if
(fragment.hasSyntheticParameters()) {
throw
new
IllegalArgumentException(
"Parameters in a view specification must be named (non-synthetic): '"
+ viewTemplateName +
"'"
);
}
context.setVariables(nameFragmentParameters);
}
}
String
templateContentType =
this
.getContentType();
Locale templateLocale =
this
.getLocale();
String
templateCharacterEncoding =
this
.getCharacterEncoding();
Set
processMarkupSelectors;
if
(markupSelectors !=
null
&& markupSelectors.size() >
0
) {
if
(markupSelectorsToRender !=
null
&& markupSelectorsToRender.size() >
0
) {
throw
new
IllegalArgumentException(
"A markup selector has been specified ("
+ Arrays.asList(markupSelectors) +
") for a view that was already being executed as a fragment ("
+ Arrays.asList(markupSelectorsToRender) +
"). Only one fragment selection is allowed."
);
}
processMarkupSelectors = markupSelectors;
}
else
if
(markupSelectorsToRender !=
null
&& markupSelectorsToRender.size() >
0
) {
processMarkupSelectors = markupSelectorsToRender;
}
else
{
processMarkupSelectors =
null
;
}
response.setLocale(templateLocale);
if
(!
this
.getForceContentType()) {
String
computedContentType = SpringContentTypeUtils.computeViewContentType(request, templateContentType !=
null
? templateContentType :
"text/html;charset=ISO-8859-1"
, templateCharacterEncoding !=
null
? Charset.forName(templateCharacterEncoding) :
null
);
response.setContentType(computedContentType);
}
else
{
if
(templateContentType !=
null
) {
response.setContentType(templateContentType);
}
else
{
response.setContentType(
"text/html;charset=ISO-8859-1"
);
}
if
(templateCharacterEncoding !=
null
) {
response.setCharacterEncoding(templateCharacterEncoding);
}
}
boolean producePartialOutputWhileProcessing =
this
.getProducePartialOutputWhileProcessing();
Writer templateWriter = producePartialOutputWhileProcessing ? response.getWriter() :
new
FastStringWriter(
1024
);
viewTemplateEngine.process(templateName, processMarkupSelectors, context, (Writer)templateWriter);
if
(!producePartialOutputWhileProcessing) {
response.getWriter().write(templateWriter.toString());
response.getWriter().flush();
}
}
}
其中条件判断viewTemplateName.contains("::")会检查viewTemplateName是否包含“::”,当包含“::”时会对viewTemplateName进行预处理调用StandardExpressions.getExpressionParser。
static
IStandardExpression parseExpression(IExpressionContext context,
String
input,
boolean
preprocess) {
IEngineConfiguration configuration = context.getConfiguration();
String
preprocessedInput = preprocess ? StandardExpressionPreprocessor.preprocess(context, input) : input;
IStandardExpression cachedExpression = ExpressionCache.getExpressionFromCache(configuration, preprocessedInput);
if
(cachedExpression !=
null
) {
return
cachedExpression;
}
else
{
Expression expression = Expression.parse(preprocessedInput.trim());
if
(expression ==
null
) {
throw
new
TemplateProcessingException(
"Could not parse as expression: ""
+ input +
"""
);
}
else
{
ExpressionCache.putExpressionIntoCache(configuration, preprocessedInput, expression);
return
expression;
}
}
}
private
static
final char PREPROCESS_DELIMITER =
'_'
;
private
static
final
String
PREPROCESS_EVAL =
"\_\_(.*?)\_\_"
;
private
static
final Pattern PREPROCESS_EVAL_PATTERN = Pattern.compile(
"\_\_(.*?)\_\_"
,
32
);
static
String
preprocess(IExpressionContext context,
String
input) {
if
(input.indexOf(
95
) ==
-1
) {
return
input;
}
else
{
IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(context.getConfiguration());
if
(!(expressionParser
instanceof
StandardExpressionParser)) {
return
input;
}
else
{
Matcher matcher = PREPROCESS_EVAL_PATTERN.matcher(input);
if
(!matcher.find()) {
return
checkPreprocessingMarkUnescaping(input);
}
else
{
StringBuilder strBuilder =
new
StringBuilder(input.length() +
24
);
int curr =
0
;
String
remaining;
do
{
remaining = checkPreprocessingMarkUnescaping(input.substring(curr, matcher.start(
0
)));
String
expressionText = checkPreprocessingMarkUnescaping(matcher.group(
1
));
strBuilder.append(remaining);
IStandardExpression expression = StandardExpressionParser.parseExpression(context, expressionText,
false
);
if
(expression ==
null
) {
return
null
;
}
Object
result = expression.execute(context, StandardExpressionExecutionContext.RESTRICTED);
strBuilder.append(result);
curr = matcher.end(
0
);
}
while
(matcher.find());
remaining = checkPreprocessingMarkUnescaping(input.substring(curr));
strBuilder.append(remaining);
return
strBuilder.toString().trim();
}
}
}
}
{new java.util.Scanner(T(java.lang.Runtime).getRuntime().
exec
(
"whoami"
).getInputStream()).next()}__::.x
0x04 其他
在进行Java代码审计分析及调试时需要对Java常见的设计模式及类加载机制有所了解,特别是在寻找实现类及方法时中间可能涉及继承和接口实现,或者我们也可以在使用IDEA等工具进行调试时在上面Http请求流程中的任意一个方法中打断点,当进入断点时可以借助工具查看方法调用栈来分析代码的执行逻辑,甚至可以在debug时修改变量值进行调试。
IDEA下debug调用栈示例如下:
调试时直接修改变量值:![SpringBoot Http请求分析 SpringBoot Http请求分析]()
0x05 参考文档
类加载机制:https://juejin.cn/post/6844903564804882445
设计模式:https://cloud.tencent.com/developer/article/1602270
小皮面板:https://www.xp.cn/download.html
银河实验室
好 文 回 顾
点赞、分享,感谢你的阅读▼
原文始发于微信公众号(平安集团安全应急响应中心):SpringBoot Http请求分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论